graph_weaver 0.6.1 → 0.7.1
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/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- 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 +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -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 +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -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 +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
require "sorbet-runtime"
|
|
5
5
|
|
|
6
|
+
require_relative "internal"
|
|
7
|
+
require_relative "internal/test_clients"
|
|
8
|
+
|
|
6
9
|
module GraphWeaver
|
|
7
10
|
# Called by generated code — not semver'd for direct use.
|
|
8
11
|
#
|
|
@@ -14,7 +17,8 @@ module GraphWeaver
|
|
|
14
17
|
# those are the point.
|
|
15
18
|
#
|
|
16
19
|
# Resolution order, per the docs: per call → per module (`MyQuery.client =`)
|
|
17
|
-
# →
|
|
20
|
+
# → a test mode's stand-in (Internal::TestClients) → the module's baked
|
|
21
|
+
# DEFAULT_CLIENT → `GraphWeaver.client`.
|
|
18
22
|
module QueryModule
|
|
19
23
|
extend T::Sig
|
|
20
24
|
|
|
@@ -30,13 +34,43 @@ module GraphWeaver
|
|
|
30
34
|
|
|
31
35
|
private
|
|
32
36
|
|
|
37
|
+
# The one call a generated `execute` makes: resolve the client, run this
|
|
38
|
+
# module's own operation, hand the raw response back for from_response to
|
|
39
|
+
# wrap. Here rather than emitted, so what has to BRACKET a request — the
|
|
40
|
+
# graph label today — costs nothing in every generated file, and one
|
|
41
|
+
# reading of it covers every module in the app.
|
|
42
|
+
#
|
|
43
|
+
# The constants come off the module rather than the caller: a generated
|
|
44
|
+
# `execute` already knows them, but reading them here is what makes this
|
|
45
|
+
# the whole of the call instead of three arguments' worth of it.
|
|
46
|
+
sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
|
|
47
|
+
def dispatch(variables, client:)
|
|
48
|
+
# A value with no JSON form is a bug in the call, not in the client that
|
|
49
|
+
# would have carried it — so it is refused here, where every mode passes,
|
|
50
|
+
# rather than in the transport, which :in_process and :fake never reach.
|
|
51
|
+
# (A transport asks the same question of a raw query string, which never
|
|
52
|
+
# comes through here.)
|
|
53
|
+
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
54
|
+
|
|
55
|
+
mod = T.unsafe(self)
|
|
56
|
+
# the graph codegen baked in, never one inferred from the client — a
|
|
57
|
+
# wrong label on a request is worse than no label
|
|
58
|
+
graph = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
59
|
+
|
|
60
|
+
GraphWeaver::Internal::Log.with_graph(graph) do
|
|
61
|
+
client_for(client).execute(mod.const_get(:QUERY), variables:,
|
|
62
|
+
operation_name: mod.const_get(:OPERATION_NAME))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
33
66
|
# The client one execute runs through: the per-call `client:`, else the
|
|
34
67
|
# module's, else the app default. Checked here so a wrong one names the
|
|
35
68
|
# contract and the module, rather than surfacing as a NoMethodError from
|
|
36
|
-
# inside the call.
|
|
69
|
+
# inside the call — and put through Client.instrumented, the one place a
|
|
70
|
+
# bare schema class gets the seam it has no way to carry itself.
|
|
37
71
|
sig { params(override: T.untyped).returns(T.untyped) }
|
|
38
72
|
def client_for(override)
|
|
39
|
-
target = override || client
|
|
73
|
+
target = GraphWeaver::Client.instrumented(override || client)
|
|
40
74
|
return target if target.respond_to?(:execute)
|
|
41
75
|
|
|
42
76
|
# Kernel.raise: this module is extended into another, so sorbet can't
|
|
@@ -48,9 +82,16 @@ module GraphWeaver
|
|
|
48
82
|
# Codegen's `client:` constant, emitted as a DEFAULT_CLIENT lambda so the
|
|
49
83
|
# constant it names is resolved on first use rather than at load — a
|
|
50
84
|
# generated file may load before the initializer that builds the client.
|
|
85
|
+
#
|
|
86
|
+
# A test mode stands in for it: what codegen baked in is exactly what a
|
|
87
|
+
# `graphql:` tag means to replace, so a bound module is covered by the
|
|
88
|
+
# tag like every other one.
|
|
51
89
|
sig { returns(T.untyped) }
|
|
52
90
|
def default_client
|
|
53
91
|
mod = T.unsafe(self)
|
|
92
|
+
stand_in = GraphWeaver::Internal::TestClients.for(mod)
|
|
93
|
+
return stand_in if stand_in
|
|
94
|
+
|
|
54
95
|
mod.const_defined?(:DEFAULT_CLIENT, false) ? mod.const_get(:DEFAULT_CLIENT).call : GraphWeaver.client!
|
|
55
96
|
end
|
|
56
97
|
end
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -19,36 +19,217 @@
|
|
|
19
19
|
# next request, the way editing a route or a locale takes effect.
|
|
20
20
|
class GraphWeaver::Railtie < Rails::Railtie
|
|
21
21
|
# config.graph_weaver.watch — false to never regenerate during a request.
|
|
22
|
-
# Default: development only.
|
|
23
|
-
config.graph_weaver =
|
|
22
|
+
# Default: development only. Every other setting is a top-level one, and an
|
|
23
|
+
# OrderedOptions would take `config.graph_weaver.queries_paths = ...` without
|
|
24
|
+
# a word and do nothing with it, so this refuses what it doesn't read.
|
|
25
|
+
class Options < ActiveSupport::OrderedOptions
|
|
26
|
+
KEYS = %i[watch].freeze
|
|
27
|
+
|
|
28
|
+
def method_missing(name, *args)
|
|
29
|
+
key = name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
|
|
30
|
+
return super if KEYS.include?(key)
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, refusal(key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# the other door: `config.graph_weaver[:queries_paths] = ...` reaches
|
|
36
|
+
# Hash#[]= without passing method_missing, and was the exact silent no-op
|
|
37
|
+
# the refusal exists to prevent. store is Hash's own synonym for it, so it
|
|
38
|
+
# stays one.
|
|
39
|
+
def []=(key, value)
|
|
40
|
+
raise ArgumentError, refusal(key) unless KEYS.include?(key.to_sym)
|
|
41
|
+
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
alias_method :store, :[]=
|
|
45
|
+
|
|
46
|
+
def respond_to_missing?(name, _private = false)
|
|
47
|
+
KEYS.include?(name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def refusal(key)
|
|
53
|
+
near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
|
|
54
|
+
fix =
|
|
55
|
+
if GraphWeaver.respond_to?(:"#{key}=") then " — GraphWeaver.#{key} = ... is the setting you want"
|
|
56
|
+
elsif near then " (did you mean #{near}?)"
|
|
57
|
+
end
|
|
58
|
+
"config.graph_weaver takes #{KEYS.join(", ")}, not #{key}#{fix}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
config.graph_weaver = Options.new
|
|
24
63
|
|
|
25
64
|
class << self
|
|
26
65
|
# The file watcher, so the to_prepare block below can ask it whether a
|
|
27
66
|
# query changed. nil when not watching.
|
|
28
67
|
attr_accessor :watcher
|
|
68
|
+
|
|
69
|
+
# What ignore_generated actually hid, resolved. Zeitwerk only reads its
|
|
70
|
+
# ignore list at setup, so anything that arrives later isn't hidden by
|
|
71
|
+
# calling ignore again — check_generated_ignored! refuses instead.
|
|
72
|
+
attr_accessor :ignored_dirs
|
|
29
73
|
end
|
|
30
74
|
|
|
31
75
|
rake_tasks do
|
|
32
76
|
require "graph_weaver/tasks"
|
|
33
77
|
end
|
|
34
78
|
|
|
79
|
+
# The two auto-wires an app gets for free, and the only two it can turn off
|
|
80
|
+
# by assigning nil. They are declared FIRST, and `before:
|
|
81
|
+
# :load_config_initializers`, and both of those are load-bearing:
|
|
82
|
+
#
|
|
83
|
+
# Rails gives an initializer an implicit `after:` of the previous one in the
|
|
84
|
+
# same railtie (Initializable#initializer: `opts[:after] ||=
|
|
85
|
+
# initializers.last&.name`). Declared after ignore_generated, which is
|
|
86
|
+
# `after: :load_config_initializers`, these two inherited that position —
|
|
87
|
+
# they ran AFTER config/initializers, so the `if nil?` fallback overwrote an
|
|
88
|
+
# app that had just said nil and the documented PII opt-out did nothing,
|
|
89
|
+
# silently, while queries and variables kept reaching a debug Rails.logger.
|
|
90
|
+
# Adding the `before:` without moving them is a TSort::Cyclic at boot, since
|
|
91
|
+
# the implicit `after:` would then point through ignore_generated.
|
|
92
|
+
#
|
|
93
|
+
# Rails.logger, unless the app already chose one (set
|
|
94
|
+
# GraphWeaver.logger = nil in an initializer to silence)
|
|
95
|
+
initializer "graph_weaver.logger", before: :load_config_initializers do
|
|
96
|
+
GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# An APM sees every GraphQL call without the app configuring anything:
|
|
100
|
+
# the ActiveSupport::Notifications adapter from docs/logging.md, plus the
|
|
101
|
+
# LogSubscriber that turns its event into one line. Measured at ~4.5µs per
|
|
102
|
+
# execution all told (0.13µs of that ActiveSupport::Notifications itself
|
|
103
|
+
# with nothing subscribed; the rest is its Event machinery) — 0.05% of a
|
|
104
|
+
# 10ms round trip, so there is nothing to weigh.
|
|
105
|
+
#
|
|
106
|
+
# An instrumenter the app set is never replaced: assigned before this (in
|
|
107
|
+
# config/application.rb) the nil check leaves it, and config/initializers now
|
|
108
|
+
# genuinely runs later, so one assigned there wins on its own — including
|
|
109
|
+
# `GraphWeaver.instrumenter = nil` to opt out.
|
|
110
|
+
initializer "graph_weaver.instrumentation", before: :load_config_initializers do
|
|
111
|
+
next unless defined?(ActiveSupport::Notifications)
|
|
112
|
+
|
|
113
|
+
if GraphWeaver.instrumenter.nil?
|
|
114
|
+
GraphWeaver.instrumenter = lambda do |event, payload, &block|
|
|
115
|
+
ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ActiveSupport::LogSubscriber is one of ActiveSupport's own eager
|
|
120
|
+
# autoloads, so naming it is enough — no require of theirs needed
|
|
121
|
+
require "graph_weaver/log_subscriber"
|
|
122
|
+
# idempotent — Subscriber.add_event_subscriber skips a pattern it already has
|
|
123
|
+
GraphWeaver::LogSubscriber.attach_to :graph_weaver
|
|
124
|
+
end
|
|
125
|
+
|
|
35
126
|
# generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
|
|
36
127
|
# Generated::PersonQuery from the path — and app/graphql/generated is
|
|
37
128
|
# inside an autoload root by default, so eager loading raised
|
|
38
129
|
# "uninitialized constant Generated::PersonQuery" in production while
|
|
39
130
|
# development (lazy) was fine. load_generated! below requires them.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
131
|
+
#
|
|
132
|
+
# after: :load_config_initializers as well as before Zeitwerk's setup — a
|
|
133
|
+
# graph's output: is only known once the app has declared its graphs, and
|
|
134
|
+
# with only the `before:` constraint this ran ~20 initializers too early, so
|
|
135
|
+
# an output outside the conventional glob was eager loaded on top of
|
|
136
|
+
# load_generated! and died on a redefined enum.
|
|
137
|
+
initializer "graph_weaver.ignore_generated",
|
|
138
|
+
after: :load_config_initializers, before: :setup_main_autoloader do
|
|
139
|
+
# patterns, not paths — generated_paths may be globs, and Zeitwerk
|
|
140
|
+
# expands its own at setup (which is what this runs before)
|
|
141
|
+
dirs = GraphWeaver::Internal::Util.generated_dirs.map { GraphWeaver::Railtie.autoload_path(_1) }
|
|
142
|
+
GraphWeaver::Railtie.check_autoload_once!(dirs)
|
|
143
|
+
GraphWeaver::Railtie.ignored_dirs = dirs
|
|
144
|
+
Rails.autoloaders.each { |loader| dirs.each { |path| loader.ignore(path) } }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The `once` autoloader is set up in bootstrap, and Zeitwerk reads its ignore
|
|
148
|
+
# list only at setup — so the `loader.ignore` above, which runs after
|
|
149
|
+
# config/initializers, hides nothing from it however the output is spelled.
|
|
150
|
+
# The generic advice ("name it in GraphWeaver.generated_paths from
|
|
151
|
+
# config/initializers") produced byte-identical output for this one, so it
|
|
152
|
+
# gets its own refusal, naming the place that is still early enough.
|
|
153
|
+
def self.check_autoload_once!(dirs)
|
|
154
|
+
return unless Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:once)
|
|
155
|
+
|
|
156
|
+
once = Rails.autoloaders.once
|
|
157
|
+
dirs.each do |dir|
|
|
158
|
+
next unless autoloaded?(once, dir)
|
|
159
|
+
|
|
160
|
+
subject, short = describe_output(dir)
|
|
161
|
+
raise GraphWeaver::Error,
|
|
162
|
+
"#{subject} is under config.autoload_once_paths, which Rails sets the `once` autoloader up on " \
|
|
163
|
+
"before config/initializers run — so nothing GraphWeaver can do from there hides it, and its " \
|
|
164
|
+
"modules can't load. Hide it in config/application.rb, which is still early enough: " \
|
|
165
|
+
"Rails.autoloaders.once.ignore(Rails.root.join(#{short.inspect})) — or generate somewhere that " \
|
|
166
|
+
"is not an autoload-once path."
|
|
45
167
|
end
|
|
46
168
|
end
|
|
47
169
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
170
|
+
# A generated path as ZEITWERK sees it: resolved, and with symlinks followed,
|
|
171
|
+
# because Zeitwerk walks real directories. Ignoring a symlinked output hid it
|
|
172
|
+
# under a name Zeitwerk never visits, and the refusal below compared that same
|
|
173
|
+
# name against real autoload roots and so never fired — including for an
|
|
174
|
+
# absolute output through a symlinked ancestor, the Capistrano current/ shape.
|
|
175
|
+
# Only this seam needs it: everywhere else a path stays the setting expanded,
|
|
176
|
+
# so what the gem reports is what you wrote.
|
|
177
|
+
def self.autoload_path(path)
|
|
178
|
+
resolved = GraphWeaver::Internal::Util.resolve(path)
|
|
179
|
+
File.exist?(resolved) ? File.realpath(resolved) : resolved
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Whether this loader would actually try to load `dir`: one of its roots
|
|
183
|
+
# contains it and its own ignore list doesn't cover it. Asking only about the
|
|
184
|
+
# roots refused an app that had called Rails.autoloaders.main.ignore(dir)
|
|
185
|
+
# itself, and told it the directory couldn't be hidden — when it already was.
|
|
186
|
+
#
|
|
187
|
+
# Zeitwerk answers that question under two names: `ignores?` was public until
|
|
188
|
+
# 2.6.1 made it internal, which publishes it as `__ignores?`. Neither present
|
|
189
|
+
# (some other loader in the slot) falls back to refusing, which is what this
|
|
190
|
+
# did for everyone before.
|
|
191
|
+
IGNORES = %i[__ignores? ignores?].freeze
|
|
192
|
+
|
|
193
|
+
def self.autoloaded?(loader, dir)
|
|
194
|
+
return false unless loader.dirs.any? { |root| dir.start_with?("#{root}/") }
|
|
195
|
+
|
|
196
|
+
asked = IGNORES.find { |name| loader.respond_to?(name) }
|
|
197
|
+
asked.nil? || !loader.public_send(asked, dir)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# How a refusal names a generated directory: what writes it, and the path the
|
|
201
|
+
# way the graph itself spells it — not the symlink target autoload_path
|
|
202
|
+
# resolved to, since the advice has to name something the reader can find in
|
|
203
|
+
# their own config.
|
|
204
|
+
def self.describe_output(dir)
|
|
205
|
+
graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
|
|
206
|
+
short = GraphWeaver::Internal::Util.relative(GraphWeaver::Internal::Util.resolve(graph&.output || dir))
|
|
207
|
+
["#{graph ? "graph :#{graph.name}'s output" : "generated path"} #{short}", short]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A graph declared from to_prepare — what the docs say to do when its block
|
|
211
|
+
# names an autoloaded constant — is declared after Zeitwerk is set up, and
|
|
212
|
+
# Zeitwerk reads its ignore list only then. So an output that arrives that
|
|
213
|
+
# late can't be hidden: its files load as ordinary autoloads and raise on the
|
|
214
|
+
# constant they don't define, in a Zeitwerk error that blames a dropped
|
|
215
|
+
# extend_type. Refuse, and name what actually happened.
|
|
216
|
+
def self.check_generated_ignored!
|
|
217
|
+
# no autoloaders, no Zeitwerk, nothing to refuse
|
|
218
|
+
return unless Rails.respond_to?(:autoloaders)
|
|
219
|
+
|
|
220
|
+
late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } - Array(ignored_dirs)
|
|
221
|
+
return if late.empty?
|
|
222
|
+
|
|
223
|
+
late.each do |dir|
|
|
224
|
+
next unless Rails.autoloaders.any? { |loader| autoloaded?(loader, dir) }
|
|
225
|
+
|
|
226
|
+
subject, short = describe_output(dir)
|
|
227
|
+
raise GraphWeaver::Error,
|
|
228
|
+
"#{subject} was declared after Rails " \
|
|
229
|
+
"set Zeitwerk up on it, so it can't be hidden from autoloading and its modules can't load. Declare " \
|
|
230
|
+
"the graph in config/initializers (schema -> { MyApp::Schema } resolves an autoloaded class when " \
|
|
231
|
+
"generation asks), or name #{short.inspect} in GraphWeaver.generated_paths there."
|
|
232
|
+
end
|
|
52
233
|
end
|
|
53
234
|
|
|
54
235
|
# The app already declared what is sensitive, so variables logged at debug
|
|
@@ -71,8 +252,25 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
71
252
|
#
|
|
72
253
|
# after: :load_config_initializers — that's where an app moves
|
|
73
254
|
# queries_paths, and the finisher that reads app.reloaders runs later still.
|
|
255
|
+
#
|
|
256
|
+
# to_prepare, not the initializer itself: a graph declared from one of those
|
|
257
|
+
# (what the docs say to do when its block names an autoloaded constant) isn't
|
|
258
|
+
# declared until every initializer has run, and a watcher built before it
|
|
259
|
+
# watched the default queries_paths — an edit to that graph's .graphql
|
|
260
|
+
# silently never regenerated. to_prepare blocks run in registration order and
|
|
261
|
+
# an app registers its own during :load_config_initializers, which this is
|
|
262
|
+
# `after:`, so every graph is in by the time this runs; app.reloaders is read
|
|
263
|
+
# per request, so joining it this late still counts. Once, though: a dev
|
|
264
|
+
# reload re-runs to_prepare, and a second watcher is a second reloader over
|
|
265
|
+
# the same files.
|
|
74
266
|
initializer "graph_weaver.watch", after: :load_config_initializers do |app|
|
|
75
|
-
|
|
267
|
+
watched = false
|
|
268
|
+
app.config.to_prepare do
|
|
269
|
+
next if watched
|
|
270
|
+
|
|
271
|
+
watched = true
|
|
272
|
+
GraphWeaver::Railtie.watch!(app)
|
|
273
|
+
end
|
|
76
274
|
end
|
|
77
275
|
|
|
78
276
|
# Registers the watcher, and says so: this is the one thing GraphWeaver does
|
|
@@ -87,7 +285,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
87
285
|
|
|
88
286
|
# a directory that doesn't exist yet is still watched — FileUpdateChecker
|
|
89
287
|
# re-globs on every check, and its keys may themselves be globs
|
|
90
|
-
watched = GraphWeaver.
|
|
288
|
+
watched = GraphWeaver.graphs.flat_map(&:queries) | GraphWeaver.fragments_paths
|
|
91
289
|
# the dump codegen would read, or where it goes once someone takes one
|
|
92
290
|
dump = GraphWeaver::SchemaLoader.locate_path || GraphWeaver.schema_path
|
|
93
291
|
|
|
@@ -96,7 +294,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
96
294
|
app.reloaders << watcher
|
|
97
295
|
GraphWeaver::Internal::Log.log(:info) do
|
|
98
296
|
"watching #{(watched << GraphWeaver::Internal::Util.relative(dump)).join(", ")} — an edit regenerates " \
|
|
99
|
-
"#{GraphWeaver.
|
|
297
|
+
"#{GraphWeaver.graphs.map(&:output).uniq.join(", ")} before the next request " \
|
|
100
298
|
"(config.graph_weaver.watch = false to stop)"
|
|
101
299
|
end
|
|
102
300
|
watcher
|
|
@@ -109,13 +307,15 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
109
307
|
# one error per save, and the next save that compiles takes.
|
|
110
308
|
def self.regenerate!
|
|
111
309
|
GraphWeaver.generate!
|
|
112
|
-
GraphWeaver.reload_generated!
|
|
113
310
|
changed = GraphWeaver.changed_files
|
|
311
|
+
# before reloading, not after: reloading logs a line of its own, and
|
|
312
|
+
# "loaded 4 generated module(s)" ahead of "regenerated ..." reads backwards
|
|
114
313
|
GraphWeaver::Internal::Log.log(:info) do
|
|
115
314
|
next "generated modules already up to date" if changed.empty?
|
|
116
315
|
|
|
117
316
|
"regenerated #{changed.join(", ")}"
|
|
118
317
|
end
|
|
318
|
+
GraphWeaver.reload_generated!
|
|
119
319
|
rescue GraphWeaver::Error => e
|
|
120
320
|
GraphWeaver::Internal::Log.log(:error) { "keeping the modules already loaded — #{e.message}" }
|
|
121
321
|
end
|
|
@@ -136,6 +336,10 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
136
336
|
# :environment, so boot failed before the task that would regenerate it.
|
|
137
337
|
next if GraphWeaver.skip_generated_load
|
|
138
338
|
|
|
339
|
+
# the app's own to_prepare blocks have run by now, so this is the first
|
|
340
|
+
# point that sees every graph — and the last before one of them loads
|
|
341
|
+
GraphWeaver::Railtie.check_generated_ignored!
|
|
342
|
+
|
|
139
343
|
# Regenerate first, then load — and here rather than in the watcher's own
|
|
140
344
|
# to_run, so an extend_type or register_enum the app registers in its own
|
|
141
345
|
# to_prepare is already in place (that block was registered at
|
|
@@ -144,8 +348,24 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
144
348
|
next if GraphWeaver::Railtie.watcher&.execute_if_updated
|
|
145
349
|
|
|
146
350
|
# entries may be globs, so Dir[] rather than Dir.exist?
|
|
147
|
-
generated = GraphWeaver.
|
|
148
|
-
|
|
351
|
+
generated = GraphWeaver::Internal::Util.generated_dirs.any? do |dir|
|
|
352
|
+
Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
|
|
353
|
+
end
|
|
354
|
+
next unless generated
|
|
355
|
+
|
|
356
|
+
# `require` no-ops on a file it has already read — which is what we want,
|
|
357
|
+
# except when the constant that file defined is gone. A graph's
|
|
358
|
+
# `namespace:` is normally a module Zeitwerk owns (app/graphql/accounts/
|
|
359
|
+
# implies Accounts), and unloading it on a dev reload takes the generated
|
|
360
|
+
# module nested inside it with it; require then restores nothing and every
|
|
361
|
+
# request 500s on "uninitialized constant Accounts::PersonQuery" until a
|
|
362
|
+
# .graphql edit happens to trigger the watcher. An un-namespaced module
|
|
363
|
+
# defines a top-level constant Zeitwerk never manages, so it survives.
|
|
364
|
+
if GraphWeaver.graphs.any?(&:namespace)
|
|
365
|
+
GraphWeaver.reload_generated!
|
|
366
|
+
else
|
|
367
|
+
GraphWeaver.load_generated!
|
|
368
|
+
end
|
|
149
369
|
end
|
|
150
370
|
end
|
|
151
371
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "internal/refusal"
|
|
5
6
|
|
|
6
7
|
module GraphWeaver
|
|
7
8
|
# Called by generated code — not semver'd for direct use.
|
|
@@ -20,6 +21,12 @@ module GraphWeaver
|
|
|
20
21
|
# representation is a Hash a module function builds, so there is no generated
|
|
21
22
|
# struct to name.
|
|
22
23
|
module Representation
|
|
24
|
+
# Marks a key path hop the schema declares as a list: `@key(fields: "id
|
|
25
|
+
# lineItems { sku }")` over a `[LineItem!]!` flattens to
|
|
26
|
+
# "lineItems[].sku". A representation is built from these paths and
|
|
27
|
+
# nothing else, so this is the only place list-ness is written down.
|
|
28
|
+
LIST_HOP = "[]"
|
|
29
|
+
|
|
23
30
|
# `key_sets` is the entity's @key field sets as dotted paths, in
|
|
24
31
|
# declaration order — [["upc", "sku"], ["id"]] for a type keyed either
|
|
25
32
|
# way. The first fully-supplied one wins; the wire hash carries exactly
|
|
@@ -29,7 +36,7 @@ module GraphWeaver
|
|
|
29
36
|
raise incomplete(type_name, values, key_sets) unless satisfied
|
|
30
37
|
|
|
31
38
|
satisfied.each_with_object({ "__typename" => type_name }) do |path, wire|
|
|
32
|
-
|
|
39
|
+
graft(wire, values, path.split("."))
|
|
33
40
|
end
|
|
34
41
|
end
|
|
35
42
|
|
|
@@ -44,11 +51,13 @@ module GraphWeaver
|
|
|
44
51
|
|
|
45
52
|
yield value
|
|
46
53
|
rescue StandardError => e
|
|
47
|
-
shown = value
|
|
54
|
+
shown = Internal::Redact.shown(value)
|
|
48
55
|
got = " (got #{shown})" unless e.message.include?(shown)
|
|
49
56
|
raise InputError.new(
|
|
50
57
|
"#{type_name} representation #{name}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
|
|
51
|
-
|
|
58
|
+
kind: Internal::Refusal.kind_of(e), path: [name], coordinate: "#{type_name}.#{name}",
|
|
59
|
+
value: Internal::Redact.value(name, value), details: Internal::Refusal.details_of(e),
|
|
60
|
+
struct: type_name,
|
|
52
61
|
)
|
|
53
62
|
end
|
|
54
63
|
|
|
@@ -57,32 +66,61 @@ module GraphWeaver
|
|
|
57
66
|
|
|
58
67
|
# Nested key values arrive as a caller-built hash, so accept either key
|
|
59
68
|
# flavour at every hop — a literal `{ id: "1" }` reads the same as a hash
|
|
60
|
-
# round-tripped through JSON.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
# round-tripped through JSON. A LIST_HOP has to BE a list, and the rest of
|
|
70
|
+
# the path reads through every element: supplied only if all of them are,
|
|
71
|
+
# so one object where the schema says list reads as absent rather than
|
|
72
|
+
# going onto the wire misshapen.
|
|
73
|
+
def self.dig(scope, hops)
|
|
74
|
+
hops = hops.split(".") if hops.is_a?(String)
|
|
75
|
+
return scope if hops.empty?
|
|
76
|
+
return unless scope.is_a?(Hash)
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
hop, *rest = hops
|
|
79
|
+
value = fetch(scope, hop.delete_suffix(LIST_HOP))
|
|
80
|
+
return dig(value, rest) unless hop.end_with?(LIST_HOP)
|
|
81
|
+
return unless value.is_a?(Array)
|
|
82
|
+
return value if rest.empty?
|
|
83
|
+
|
|
84
|
+
each = value.map { |item| dig(item, rest) }
|
|
85
|
+
each unless each.any?(&:nil?)
|
|
67
86
|
end
|
|
68
87
|
private_class_method :dig
|
|
69
88
|
|
|
70
|
-
def self.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
def self.fetch(hash, name) = hash.key?(name) ? hash[name] : hash[name.to_sym]
|
|
90
|
+
private_class_method :fetch
|
|
91
|
+
|
|
92
|
+
# One key path, copied onto the wire in the shape the path declares — a
|
|
93
|
+
# LIST_HOP stays a list of objects, one per element, rather than
|
|
94
|
+
# collapsing into the single object a subgraph would read as one entity.
|
|
95
|
+
def self.graft(wire, source, hops)
|
|
96
|
+
hop, *rest = hops
|
|
97
|
+
name = hop.delete_suffix(LIST_HOP)
|
|
98
|
+
value = fetch(source, name)
|
|
99
|
+
return wire[name] = value if rest.empty?
|
|
100
|
+
|
|
101
|
+
if hop.end_with?(LIST_HOP)
|
|
102
|
+
elements = (wire[name] ||= Array.new(value.size) { {} })
|
|
103
|
+
value.each_with_index { |item, index| graft(elements[index], item, rest) }
|
|
104
|
+
else
|
|
105
|
+
graft(wire[name] ||= {}, value, rest)
|
|
106
|
+
end
|
|
74
107
|
end
|
|
75
|
-
private_class_method :
|
|
108
|
+
private_class_method :graft
|
|
76
109
|
|
|
77
110
|
# Name the type and what it's short of, per @key — with a single key
|
|
78
|
-
# there's one answer, so it also fills InputError#
|
|
111
|
+
# there's one answer, so it also fills InputError#path.
|
|
79
112
|
def self.incomplete(type_name, values, key_sets)
|
|
80
113
|
gaps = key_sets.map { |paths| missing(values, paths) }
|
|
81
114
|
|
|
82
115
|
if key_sets.one?
|
|
116
|
+
# a nested @key reads "organization.id"; #path is that route, without
|
|
117
|
+
# the list markers the message keeps
|
|
118
|
+
path = gaps.first.one? ? gaps.first.first.split(".").map { |hop| hop.delete_suffix(LIST_HOP) } : []
|
|
83
119
|
InputError.new(
|
|
84
120
|
"#{type_name} representation is missing @key #{gaps.first.map(&:inspect).join(", ")}",
|
|
85
|
-
|
|
121
|
+
kind: :missing, path:,
|
|
122
|
+
coordinate: ("#{type_name}.#{path.first}" if path.one?),
|
|
123
|
+
struct: type_name,
|
|
86
124
|
)
|
|
87
125
|
else
|
|
88
126
|
alternatives = key_sets.zip(gaps).map do |paths, gap|
|
|
@@ -93,7 +131,7 @@ module GraphWeaver
|
|
|
93
131
|
end
|
|
94
132
|
InputError.new(
|
|
95
133
|
"#{type_name} representation satisfies none of its @keys — supply #{alternatives.join(", or ")}",
|
|
96
|
-
struct: type_name,
|
|
134
|
+
kind: :missing, struct: type_name,
|
|
97
135
|
)
|
|
98
136
|
end
|
|
99
137
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# Included in every generated result struct — the twin of InputStruct on
|
|
8
|
+
# the way out. T::Struct compares by identity, has no #to_h and can't be
|
|
9
|
+
# destructured, so two results parsed from the same response come back
|
|
10
|
+
# unequal, `result.to_h` raises, and `case result in {person:}` raises
|
|
11
|
+
# NoMatchingPatternError. A struct the library hands you should behave
|
|
12
|
+
# like an ordinary Ruby object; this is that behaviour, in one place.
|
|
13
|
+
module ResultStruct
|
|
14
|
+
extend T::Sig
|
|
15
|
+
include Kernel # for sorbet: hosts are T::Structs
|
|
16
|
+
|
|
17
|
+
# Value equality over the struct's props. Nested structs compare
|
|
18
|
+
# through their own copy of this, so a whole result tree compares.
|
|
19
|
+
# eql? on the props hash rather than ==, so that this and #hash agree
|
|
20
|
+
# on what "same" means (1 and 1.0 hash differently).
|
|
21
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.instance_of?(self.class) && deconstruct_keys(nil).eql?(other.deconstruct_keys(nil))
|
|
24
|
+
end
|
|
25
|
+
alias_method :eql?, :==
|
|
26
|
+
|
|
27
|
+
# eql? and hash move together, or a struct is a broken Hash key.
|
|
28
|
+
sig { returns(Integer) }
|
|
29
|
+
def hash = [self.class, *deconstruct_keys(nil).values].hash
|
|
30
|
+
|
|
31
|
+
# Pattern matching: `case result in {person: {name:}}`. Every prop,
|
|
32
|
+
# always — the pattern binds the ones it names, and a nested struct
|
|
33
|
+
# destructures through its own copy. Values stay as they are, so
|
|
34
|
+
# `in {person: Person => p}` binds the struct rather than a hash.
|
|
35
|
+
sig { params(_keys: T.nilable(T::Array[Symbol])).returns(T::Hash[Symbol, T.untyped]) }
|
|
36
|
+
def deconstruct_keys(_keys)
|
|
37
|
+
self.class.props.keys.to_h { |prop| [prop, public_send(prop)] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The Ruby-side view of the result: snake_case prop names as Symbols,
|
|
41
|
+
# nils kept, nested structs and arrays followed, enums left as their
|
|
42
|
+
# T::Enum members.
|
|
43
|
+
#
|
|
44
|
+
# Deliberately NOT the wire shape, and not an inverse of .from_h — a
|
|
45
|
+
# registered scalar keeps whatever Ruby object its codec built. That
|
|
46
|
+
# is the same objection Response#to_h raises against re-serializing
|
|
47
|
+
# its data, and it doesn't apply here: this hash is Symbol-keyed and
|
|
48
|
+
# Ruby-cased, so it can't be mistaken for the server's response.
|
|
49
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
50
|
+
def to_h
|
|
51
|
+
deconstruct_keys(nil).transform_values { |value| unwrap_value(value) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# JSON is the wire's shape, not Ruby's: `to_json` is the generated
|
|
55
|
+
# `as_json` encoded, so it carries the response keys and each leaf back
|
|
56
|
+
# through its scalar registration's `serialize:`, and
|
|
57
|
+
# `.from_h(JSON.parse(result.to_json))` gives an equal struct. That is
|
|
58
|
+
# the opposite of #to_h, deliberately — a Symbol-keyed Ruby hash can't be
|
|
59
|
+
# mistaken for a response, and a JSON string can, so the string is the
|
|
60
|
+
# one that has to be true. (A registration with no `serialize:` has no
|
|
61
|
+
# wire form; its value passes through, exactly as it does on the way in.)
|
|
62
|
+
#
|
|
63
|
+
# Defined here rather than left to Ruby: Object#to_json writes the
|
|
64
|
+
# #inspect string, quoted, and ActiveSupport's Object#as_json writes the
|
|
65
|
+
# ivars — a result's snake_cased props, `class_` and all.
|
|
66
|
+
sig { params(options: T.untyped).returns(String) }
|
|
67
|
+
def to_json(options = nil) = as_json.to_json(options)
|
|
68
|
+
|
|
69
|
+
# Only reached by a struct generated before as_json existed — the
|
|
70
|
+
# generated override otherwise wins, being defined on the struct itself.
|
|
71
|
+
sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
|
|
72
|
+
def as_json(*_options)
|
|
73
|
+
raise GraphWeaver::Error,
|
|
74
|
+
"#{self.class} was generated before #as_json — regenerate (rake graph_weaver:generate)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Follows a value into nested result structs and lists (which nest, for
|
|
80
|
+
# a `[[Pet!]!]!`); everything else is already Ruby-side.
|
|
81
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
82
|
+
def unwrap_value(value)
|
|
83
|
+
case value
|
|
84
|
+
when ResultStruct then value.to_h
|
|
85
|
+
when Array then value.map { |item| unwrap_value(item) }
|
|
86
|
+
else value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|