graph_weaver 0.5.0 → 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 +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- 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 +45 -43
- 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 +6 -13
- 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 +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- 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 +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- 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 +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- 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,17 +61,47 @@ 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
|
|
|
73
|
+
# Called by generated code — not semver'd for direct use.
|
|
74
|
+
#
|
|
75
|
+
# Cast a response's data, keeping the server's own errors on a failure.
|
|
76
|
+
# The common cause of a cast failure is a field that came back null *with
|
|
77
|
+
# a reason attached* — a permission rule, a partial outage — and raising
|
|
78
|
+
# only Sorbet's nil complaint throws that reason away, leaving whoever is
|
|
79
|
+
# on call with a type error and no explanation.
|
|
80
|
+
sig do
|
|
81
|
+
params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
|
|
82
|
+
.returns(T.untyped)
|
|
83
|
+
end
|
|
84
|
+
def cast_data(struct, data, errors)
|
|
85
|
+
struct.from_h(data)
|
|
86
|
+
rescue GraphWeaver::TypeError => e
|
|
87
|
+
raise if errors.empty?
|
|
88
|
+
|
|
89
|
+
detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
|
|
90
|
+
raise GraphWeaver::TypeError.new(
|
|
91
|
+
struct:,
|
|
92
|
+
message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Called by generated code — not semver'd for direct use.
|
|
97
|
+
#
|
|
66
98
|
# Shape-check a raw response envelope, returning it. Generated
|
|
67
99
|
# from_response is public API taking anything with #to_h, so a malformed
|
|
68
100
|
# body has to brand rather than escape as a raw Sorbet TypeError from a sig
|
|
69
101
|
# (which fires before the struct's own rescue can see it). Lives here rather
|
|
70
102
|
# than unrolled into every generated module.
|
|
71
103
|
def check_envelope!(raw, struct)
|
|
104
|
+
raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
|
|
72
105
|
unless raw.is_a?(Hash)
|
|
73
106
|
raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
|
|
74
107
|
end
|
|
@@ -85,36 +118,37 @@ module GraphWeaver
|
|
|
85
118
|
raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
|
|
86
119
|
end
|
|
87
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
|
+
|
|
88
131
|
raw
|
|
89
132
|
end
|
|
90
133
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# Every naming site goes through here — generate!, parse(path), and
|
|
99
|
-
# load_queries! — so the constant a file produces is the same one
|
|
100
|
-
# whichever door you came in by, and the file it lands in matches it.
|
|
101
|
-
def generated_names(path, source)
|
|
102
|
-
base = File.basename(path, ".*")
|
|
103
|
-
suffix = operation_suffix(source)
|
|
104
|
-
["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
|
|
105
|
-
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
|
|
106
141
|
|
|
107
|
-
#
|
|
108
|
-
|
|
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)
|
|
109
147
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
operation = GraphQL.parse(source).definitions
|
|
113
|
-
.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
114
|
-
(operation&.operation_type == "mutation") ? "Mutation" : "Query"
|
|
115
|
-
rescue GraphQL::ParseError
|
|
116
|
-
"Query" # unparseable: codegen brands the real error a moment later
|
|
148
|
+
rails = Object.const_get(:Rails)
|
|
149
|
+
rails.root if rails.respond_to?(:root)
|
|
117
150
|
end
|
|
151
|
+
private :rails_root
|
|
118
152
|
|
|
119
153
|
# Conventional locations. Every directory setting is a LIST,
|
|
120
154
|
# factory_bot-style: extra locations (a test-only dir, an engine's) can be
|
|
@@ -131,11 +165,6 @@ module GraphWeaver
|
|
|
131
165
|
# read. A second schema is a second generate! (schema: names it).
|
|
132
166
|
attr_writer :schema_path
|
|
133
167
|
|
|
134
|
-
# Set by every graph_weaver rake task: those tasks WRITE the generated
|
|
135
|
-
# files, so loading them first lets a stale one block its own repair.
|
|
136
|
-
# None of them needs the modules loaded.
|
|
137
|
-
attr_accessor :skip_generated_load
|
|
138
|
-
|
|
139
168
|
def queries_paths = @queries_paths ||= ["app/graphql/queries"]
|
|
140
169
|
def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
|
|
141
170
|
|
|
@@ -159,11 +188,10 @@ module GraphWeaver
|
|
|
159
188
|
|
|
160
189
|
def schema_path = @schema_path || "app/graphql/schema.json"
|
|
161
190
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
end
|
|
191
|
+
# Set by every graph_weaver rake task: those tasks WRITE the generated
|
|
192
|
+
# files, so loading them first lets a stale one block its own repair.
|
|
193
|
+
# None of them needs the modules loaded.
|
|
194
|
+
attr_accessor :skip_generated_load
|
|
167
195
|
|
|
168
196
|
# The name of the shared module — the types that live once per schema
|
|
169
197
|
# (input types, enums, unions hoisted from shared fragments) and are
|
|
@@ -183,38 +211,58 @@ module GraphWeaver
|
|
|
183
211
|
#
|
|
184
212
|
# GraphWeaver.generate! # queries_paths -> generated_paths.first
|
|
185
213
|
#
|
|
186
|
-
# person.graphql => person_query.rb defining PersonQuery. Returns
|
|
187
|
-
#
|
|
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
|
|
188
217
|
# (see #orphaned), so renaming or dropping a .graphql leaves nothing
|
|
189
218
|
# behind. Pair with a freshness spec (docs/generated_modules.md).
|
|
190
219
|
def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
191
220
|
types_module: nil)
|
|
192
221
|
schema = schema ? schema_for(schema) : locate_schema!
|
|
193
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
|
+
|
|
194
229
|
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
230
|
+
@unmatched_registrations = Codegen.unmatched_registrations(schema)
|
|
231
|
+
@changed_files = []
|
|
195
232
|
written = plan.map do |filename, source|
|
|
196
|
-
target = File.join(output, filename)
|
|
233
|
+
target = File.join(Internal::Util.resolve(output), filename)
|
|
234
|
+
next target if current?(target, source)
|
|
235
|
+
|
|
197
236
|
FileUtils.mkdir_p(File.dirname(target))
|
|
198
|
-
|
|
199
|
-
|
|
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}" }
|
|
200
244
|
target
|
|
201
245
|
end
|
|
202
246
|
|
|
203
247
|
orphaned(output, written).each do |orphan|
|
|
204
248
|
File.delete(orphan)
|
|
205
|
-
log(:info) { "pruned #{orphan}" }
|
|
249
|
+
Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
|
|
206
250
|
end
|
|
207
251
|
|
|
208
|
-
written
|
|
252
|
+
written.map { |target| Internal::Util.relative(target) }
|
|
209
253
|
end
|
|
210
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
|
+
|
|
211
260
|
# Generated files under output the current plan no longer produces — a
|
|
212
261
|
# query renamed or deleted, a type dropped from the schema, a union no
|
|
213
262
|
# longer hoisted. Left alone they'd keep being required by
|
|
214
263
|
# load_generated!, resolving against a query that no longer exists.
|
|
215
264
|
def orphaned(output, produced)
|
|
216
|
-
|
|
217
|
-
generated_files(output).reject { |path| current.include?(File.expand_path(path)) }
|
|
265
|
+
generated_files(output) - produced
|
|
218
266
|
end
|
|
219
267
|
private :orphaned
|
|
220
268
|
|
|
@@ -223,7 +271,7 @@ module GraphWeaver
|
|
|
223
271
|
# safe: this is a real directory, and a hand-written file in it must
|
|
224
272
|
# survive regeneration.
|
|
225
273
|
def generated_files(output)
|
|
226
|
-
Dir[File.join(output, "**/*.rb")].sort.select do |path|
|
|
274
|
+
Dir[File.join(Internal::Util.resolve(output), "**/*.rb")].sort.select do |path|
|
|
227
275
|
File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
|
|
228
276
|
end
|
|
229
277
|
end
|
|
@@ -240,24 +288,46 @@ module GraphWeaver
|
|
|
240
288
|
# end
|
|
241
289
|
def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
242
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
|
+
|
|
243
298
|
schema = schema ? schema_for(schema) : locate_schema!
|
|
244
299
|
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
300
|
+
@unmatched_registrations = Codegen.unmatched_registrations(schema)
|
|
245
301
|
stale = plan.filter_map do |filename, source|
|
|
246
|
-
target = File.join(output, filename)
|
|
247
|
-
|
|
248
|
-
# copy is not stale generated code, so don't fail CI over it
|
|
249
|
-
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)
|
|
250
304
|
end
|
|
251
305
|
# strays: a generated file the current schema + queries no longer produce
|
|
252
|
-
stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
|
|
306
|
+
stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
|
|
253
307
|
|
|
254
308
|
unless stale.empty?
|
|
255
|
-
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(", ")}"
|
|
256
311
|
end
|
|
257
312
|
|
|
258
313
|
true
|
|
259
314
|
end
|
|
260
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
|
+
|
|
261
331
|
# Which checked-in queries no longer validate — breaking-change
|
|
262
332
|
# detection scoped to the operations you actually ship. Reports rather
|
|
263
333
|
# than raising, keyed by file, JSON-ready like every #to_h here:
|
|
@@ -287,9 +357,9 @@ module GraphWeaver
|
|
|
287
357
|
schema = schema ? schema_for(schema) : refreshed_schema
|
|
288
358
|
shared = Codegen.load_fragments(fragments)
|
|
289
359
|
|
|
290
|
-
query_files(queries).each_with_object({}) do |path, failures|
|
|
360
|
+
Internal::Util.query_files(queries).each_with_object({}) do |path, failures|
|
|
291
361
|
errors = validation_errors(schema, File.read(path), shared, table)
|
|
292
|
-
failures[path] = errors if errors.any?
|
|
362
|
+
failures[Internal::Util.relative(path)] = errors if errors.any?
|
|
293
363
|
end
|
|
294
364
|
end
|
|
295
365
|
|
|
@@ -300,7 +370,7 @@ module GraphWeaver
|
|
|
300
370
|
# nil when a live schema class is what gets checked, since the dump then
|
|
301
371
|
# isn't what the errors came from.
|
|
302
372
|
def checked_routing_table
|
|
303
|
-
return if live_schema
|
|
373
|
+
return if Internal::Util.live_schema
|
|
304
374
|
|
|
305
375
|
path = SchemaLoader.locate_path
|
|
306
376
|
return unless path&.end_with?(".graphql", ".gql")
|
|
@@ -319,31 +389,19 @@ module GraphWeaver
|
|
|
319
389
|
# class — hand-written SDL, a composed supergraph — have nothing to
|
|
320
390
|
# re-read, so they're checked as they are.
|
|
321
391
|
def refreshed_schema
|
|
322
|
-
live = live_schema
|
|
392
|
+
live = Internal::Util.live_schema
|
|
323
393
|
return live if live
|
|
324
394
|
|
|
325
395
|
# locate_schema! raises the conventional "no schema dump" message
|
|
326
396
|
path = SchemaLoader.locate_path or locate_schema!
|
|
327
|
-
|
|
328
|
-
return SchemaLoader.load(path) unless meta&.key?("url")
|
|
397
|
+
return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
|
|
329
398
|
|
|
330
|
-
|
|
399
|
+
# source_transport rather than one built here: it reads the auth ENV var
|
|
400
|
+
# the dump named, so `--auth MY_TOKEN` reaches this path too
|
|
401
|
+
SchemaLoader.introspect(SchemaLoader.source_transport(path))
|
|
331
402
|
end
|
|
332
403
|
private :refreshed_schema
|
|
333
404
|
|
|
334
|
-
# The graphql-ruby schema class the app default executes against, when it
|
|
335
|
-
# runs in-process — a Client wrapping one, or the class in the slot bare.
|
|
336
|
-
# nil for every network client. Not memoized: in dev the class object is
|
|
337
|
-
# replaced on reload. (Public because testing's :in_process mode asks:
|
|
338
|
-
# a client already running in-process names its own schema class.)
|
|
339
|
-
def live_schema
|
|
340
|
-
# through #transport, not #schema: a url client's #schema introspects,
|
|
341
|
-
# so asking it would answer this question over the network
|
|
342
|
-
target = client.is_a?(Client) ? client.transport : client
|
|
343
|
-
target = target.schema if target.is_a?(InProcess)
|
|
344
|
-
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
345
|
-
end
|
|
346
|
-
|
|
347
405
|
# One query's schema-validation errors as JSON-ready hashes, with the
|
|
348
406
|
# source position graphql-ruby reports. Unparseable counts as an error
|
|
349
407
|
# too — it doesn't validate either, and inline_fragments (which parses
|
|
@@ -403,20 +461,68 @@ module GraphWeaver
|
|
|
403
461
|
# a schema migration).
|
|
404
462
|
def load_generated!(path = nil)
|
|
405
463
|
paths = path ? [path] : generated_paths
|
|
406
|
-
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
|
|
407
465
|
files.each do |file|
|
|
408
|
-
require
|
|
466
|
+
require file
|
|
409
467
|
rescue NameError => e
|
|
468
|
+
reported = Internal::Util.relative(file)
|
|
410
469
|
# a dropped extend_type leaves this include dangling; say so here,
|
|
411
470
|
# because the raw NameError points at generated code and names no fix
|
|
412
|
-
helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1]
|
|
413
|
-
|
|
414
|
-
"
|
|
415
|
-
|
|
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
|
|
511
|
+
end
|
|
512
|
+
load_generated!
|
|
513
|
+
end
|
|
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)
|
|
416
522
|
end
|
|
417
|
-
|
|
418
|
-
files
|
|
523
|
+
owner.send(:remove_const, base) if owner.const_defined?(base, false)
|
|
419
524
|
end
|
|
525
|
+
private :undefine
|
|
420
526
|
|
|
421
527
|
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
422
528
|
# the console object and the rake task point at the same thing. A path
|
|
@@ -453,11 +559,12 @@ module GraphWeaver
|
|
|
453
559
|
|
|
454
560
|
seen = {} # module name => the file that produced it, for the collision message
|
|
455
561
|
|
|
456
|
-
plan = query_files(queries).map do |path|
|
|
562
|
+
plan = Internal::Util.query_files(queries).map do |path|
|
|
457
563
|
source = File.read(path)
|
|
458
|
-
name, filename = generated_names(path, source)
|
|
564
|
+
name, filename = Internal::Util.generated_names(path, source)
|
|
459
565
|
if (earlier = seen[name])
|
|
460
|
-
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; " \
|
|
461
568
|
"the module name comes from the file name alone (directories don't namespace it), so rename one"
|
|
462
569
|
end
|
|
463
570
|
seen[name] = path
|
|
@@ -465,7 +572,7 @@ module GraphWeaver
|
|
|
465
572
|
codegen = Codegen.new(
|
|
466
573
|
schema:,
|
|
467
574
|
query: Codegen.inline_fragments(source, shared, path),
|
|
468
|
-
|
|
575
|
+
name:,
|
|
469
576
|
client:,
|
|
470
577
|
types_namespace: types_module,
|
|
471
578
|
hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
|
|
@@ -478,7 +585,7 @@ module GraphWeaver
|
|
|
478
585
|
end
|
|
479
586
|
|
|
480
587
|
if used_unions.any? || used.values.any?(&:any?)
|
|
481
|
-
codegen = Codegen.new(schema:, query: "",
|
|
588
|
+
codegen = Codegen.new(schema:, query: "", name: types_module)
|
|
482
589
|
plan = codegen.generate_types(
|
|
483
590
|
inputs: used[:inputs], enums: used[:enums] + used[:mapped],
|
|
484
591
|
unions: used_unions, fragments: shared,
|
|
@@ -489,15 +596,6 @@ module GraphWeaver
|
|
|
489
596
|
end
|
|
490
597
|
private :generation_plan
|
|
491
598
|
|
|
492
|
-
# coerce: true for every scalar that doesn't say coerce: itself —
|
|
493
|
-
# the same switch at global scope, resolved lazily at generation time
|
|
494
|
-
# (so set it any time before you generate, no ordering dance):
|
|
495
|
-
#
|
|
496
|
-
# GraphWeaver.auto_coerce = true
|
|
497
|
-
#
|
|
498
|
-
# An explicit coerce: on a registration always wins.
|
|
499
|
-
attr_accessor :auto_coerce
|
|
500
|
-
|
|
501
599
|
# Whether generated modules/structs emit `extend T::Sig` (so `sig`
|
|
502
600
|
# resolves standalone). Default (nil) auto-detects: an app that globally
|
|
503
601
|
# injects T::Sig (`class Module; include T::Sig`) makes the per-struct
|
|
@@ -516,15 +614,7 @@ module GraphWeaver
|
|
|
516
614
|
# Whether the host app has globally injected T::Sig into every module
|
|
517
615
|
# (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
|
|
518
616
|
def global_tsig? = Module.include?(T::Sig)
|
|
519
|
-
|
|
520
|
-
# The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
|
|
521
|
-
# or nil (also nil when did_you_mean isn't loadable). One home for the guard
|
|
522
|
-
# used by codegen validation, alias resolution, and the runtime prop hints.
|
|
523
|
-
def did_you_mean(dictionary, term)
|
|
524
|
-
return unless defined?(DidYouMean::SpellChecker)
|
|
525
|
-
|
|
526
|
-
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
|
|
527
|
-
end
|
|
617
|
+
private :global_tsig?
|
|
528
618
|
|
|
529
619
|
# Teach the generator how a GraphQL custom scalar deserializes into a
|
|
530
620
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
@@ -540,12 +630,17 @@ module GraphWeaver
|
|
|
540
630
|
# Proc(expr) => code string, or :itself to force pass-through. requires:
|
|
541
631
|
# (a String or Array) names files the generated code needs — validated,
|
|
542
632
|
# and actually required to confirm it resolves when type: is a real class.
|
|
543
|
-
#
|
|
544
|
-
# raw input (
|
|
545
|
-
#
|
|
546
|
-
# 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
|
|
547
636
|
# pre-registered the same way, so this also overrides them.
|
|
548
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
|
+
#
|
|
549
644
|
# Pass a `Type.field` coordinate instead of a scalar name to override just
|
|
550
645
|
# that one field — so the same scalar can deserialize as different Ruby
|
|
551
646
|
# types across fields (a `Date` for `User.birthday`, a `Time` elsewhere):
|
|
@@ -554,8 +649,8 @@ module GraphWeaver
|
|
|
554
649
|
#
|
|
555
650
|
# A field-level override wins over the scalar-name registration. Same
|
|
556
651
|
# signature either way. Call before generating.
|
|
557
|
-
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil
|
|
558
|
-
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:)
|
|
559
654
|
end
|
|
560
655
|
|
|
561
656
|
# Map a GraphQL enum onto an app-owned T::Enum, so generated code
|
|
@@ -593,8 +688,9 @@ module GraphWeaver
|
|
|
593
688
|
end
|
|
594
689
|
|
|
595
690
|
# Every registry back to its starting state: built-in scalars restored,
|
|
596
|
-
# enum mappings and type helpers dropped — the clean slate between
|
|
597
|
-
#
|
|
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:
|
|
598
694
|
# GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
|
|
599
695
|
# .reset_type_helpers!)
|
|
600
696
|
def reset_registrations!
|
|
@@ -607,23 +703,30 @@ module GraphWeaver
|
|
|
607
703
|
#
|
|
608
704
|
# schema: is a graphql-ruby schema or a Client (its schema, and its
|
|
609
705
|
# transport as the module's default). query is a .graphql/.gql path (module
|
|
610
|
-
# name derived from the file name
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
#
|
|
615
|
-
# override, client: to bake the module's default client/transport.
|
|
706
|
+
# name derived from the file name and the operation — see #module_name) or
|
|
707
|
+
# a raw query string (name derived from the operation name, falling back to
|
|
708
|
+
# "Query" for anonymous operations — collisions are impossible since each
|
|
709
|
+
# parse gets its own container). Pass name: to override, client: to bake
|
|
710
|
+
# the module's default client/transport.
|
|
616
711
|
def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
|
|
617
712
|
client ||= schema if schema.is_a?(Client)
|
|
618
713
|
schema = schema_for(schema)
|
|
714
|
+
# Rails.root.join(...) hands you a Pathname, and to_path is the
|
|
715
|
+
# ecosystem's "I am a path" — the same conversion schema: gets through
|
|
716
|
+
# SchemaLoader.load. Without it end_with? below is a NoMethodError.
|
|
717
|
+
query = query.to_path if query.respond_to?(:to_path)
|
|
619
718
|
path = query if query.end_with?(".graphql", ".gql")
|
|
620
719
|
if path
|
|
621
|
-
query = File.read(path)
|
|
622
|
-
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"
|
|
623
726
|
end
|
|
624
727
|
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
|
|
625
728
|
|
|
626
|
-
Codegen.parse(schema:, query:,
|
|
729
|
+
Codegen.parse(schema:, query:, name:, client:, path:)
|
|
627
730
|
end
|
|
628
731
|
|
|
629
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
|
|
@@ -227,16 +227,10 @@ extra_rdoc_files: []
|
|
|
227
227
|
files:
|
|
228
228
|
- ".yardopts"
|
|
229
229
|
- CHANGELOG.md
|
|
230
|
-
- CLAUDE.md
|
|
231
|
-
- DECISIONS.md
|
|
232
230
|
- Gemfile
|
|
233
231
|
- Gemfile.lock
|
|
234
232
|
- LICENSE.txt
|
|
235
|
-
- Makefile
|
|
236
|
-
- NOTES.md
|
|
237
|
-
- PLAN.md
|
|
238
233
|
- README.md
|
|
239
|
-
- REVIEW.md
|
|
240
234
|
- docs/cassettes.md
|
|
241
235
|
- docs/editors.md
|
|
242
236
|
- docs/errors.md
|
|
@@ -260,12 +254,20 @@ files:
|
|
|
260
254
|
- lib/graph_weaver/codegen/nodes.rb
|
|
261
255
|
- lib/graph_weaver/codegen/scalar_type.rb
|
|
262
256
|
- lib/graph_weaver/codegen/type_helpers.rb
|
|
257
|
+
- lib/graph_weaver/coerce.rb
|
|
263
258
|
- lib/graph_weaver/errors.rb
|
|
264
259
|
- lib/graph_weaver/federation.rb
|
|
265
260
|
- lib/graph_weaver/hints.rb
|
|
266
261
|
- lib/graph_weaver/in_process.rb
|
|
267
262
|
- lib/graph_weaver/inflect.rb
|
|
268
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
|
|
269
271
|
- lib/graph_weaver/logging.rb
|
|
270
272
|
- lib/graph_weaver/parsing.rb
|
|
271
273
|
- lib/graph_weaver/query_module.rb
|
|
@@ -274,9 +276,8 @@ files:
|
|
|
274
276
|
- lib/graph_weaver/response.rb
|
|
275
277
|
- lib/graph_weaver/retry.rb
|
|
276
278
|
- lib/graph_weaver/rspec.rb
|
|
279
|
+
- lib/graph_weaver/schema_diff.rb
|
|
277
280
|
- lib/graph_weaver/schema_loader.rb
|
|
278
|
-
- lib/graph_weaver/schemas.rb
|
|
279
|
-
- lib/graph_weaver/selection.rb
|
|
280
281
|
- lib/graph_weaver/tasks.rb
|
|
281
282
|
- lib/graph_weaver/testing.rb
|
|
282
283
|
- lib/graph_weaver/testing/cassette.rb
|
|
@@ -285,8 +286,6 @@ files:
|
|
|
285
286
|
- lib/graph_weaver/testing/fake_client.rb
|
|
286
287
|
- lib/graph_weaver/testing/fake_subgraph.rb
|
|
287
288
|
- lib/graph_weaver/testing/router.rb
|
|
288
|
-
- lib/graph_weaver/testing/subgraphs.rb
|
|
289
|
-
- lib/graph_weaver/testing/values.rb
|
|
290
289
|
- lib/graph_weaver/transport.rb
|
|
291
290
|
- lib/graph_weaver/transport/faraday.rb
|
|
292
291
|
- lib/graph_weaver/transport/http.rb
|
|
@@ -294,7 +293,12 @@ files:
|
|
|
294
293
|
homepage: https://github.com/dpep/graph_weaver
|
|
295
294
|
licenses:
|
|
296
295
|
- MIT
|
|
297
|
-
metadata:
|
|
296
|
+
metadata:
|
|
297
|
+
bug_tracker_uri: https://github.com/dpep/graph_weaver/issues
|
|
298
|
+
changelog_uri: https://github.com/dpep/graph_weaver/blob/main/CHANGELOG.md
|
|
299
|
+
documentation_uri: https://github.com/dpep/graph_weaver/tree/main/docs
|
|
300
|
+
rubygems_mfa_required: 'true'
|
|
301
|
+
source_code_uri: https://github.com/dpep/graph_weaver
|
|
298
302
|
rdoc_options: []
|
|
299
303
|
require_paths:
|
|
300
304
|
- lib
|
|
@@ -311,5 +315,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
311
315
|
requirements: []
|
|
312
316
|
rubygems_version: 3.6.9
|
|
313
317
|
specification_version: 4
|
|
314
|
-
summary:
|
|
318
|
+
summary: A typed GraphQL client for Ruby
|
|
315
319
|
test_files: []
|