graph_weaver 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# The client a generated module runs against while a test mode is
|
|
7
|
+
# installed — the slot `graphql: :fake` and its siblings fill, and the one
|
|
8
|
+
# a `graphql_*` helper writes to.
|
|
9
|
+
#
|
|
10
|
+
# A tag used to work by swapping GraphWeaver.client, which is the LAST
|
|
11
|
+
# place a module looks: one generated with `client:` reads its baked
|
|
12
|
+
# DEFAULT_CLIENT first and never got there, so the tag quietly didn't
|
|
13
|
+
# apply. The mode installs itself here instead, and QueryModule asks
|
|
14
|
+
# before it reads that constant — so a tag reaches every module the
|
|
15
|
+
# example runs, bound or not.
|
|
16
|
+
#
|
|
17
|
+
# Keyed by the graph a module was generated from (its baked GRAPH), since
|
|
18
|
+
# the honest answer varies: :fake for a billing module has to fabricate
|
|
19
|
+
# billing's shapes, not the other schema's. A helper names its graphs the
|
|
20
|
+
# same way and lands in the same table, so what an example says applies to
|
|
21
|
+
# the modules it runs.
|
|
22
|
+
#
|
|
23
|
+
# Test-time only. Nothing installs a mode in production, where #for is an
|
|
24
|
+
# ivar read that returns nil.
|
|
25
|
+
module TestClients
|
|
26
|
+
# The app client slot under a mode in an app with several graphs. It
|
|
27
|
+
# fills the duck-typed slot the same way every other client does, and
|
|
28
|
+
# answers the one question asked of it with the reason there is no
|
|
29
|
+
# answer — the alternative is the real endpoint, silently.
|
|
30
|
+
class NoAppClient
|
|
31
|
+
def initialize(mode) = @mode = mode
|
|
32
|
+
|
|
33
|
+
def execute(_query, **)
|
|
34
|
+
graphs = GraphWeaver.graphs
|
|
35
|
+
raise GraphWeaver::Error, "#{@mode.inspect} stands in for a graph's modules, and this " \
|
|
36
|
+
"app has #{graphs.size} graphs (#{graphs.map { |g| g.name.inspect }.join(", ")}) — so " \
|
|
37
|
+
"GraphWeaver.client has no one right answer, and this request would have gone to the " \
|
|
38
|
+
"real endpoint. A generated module runs against its own graph's stand-in; to reach one " \
|
|
39
|
+
"directly, call the client a helper returns (graphql_fake(graph: #{graphs.first.name.inspect}), " \
|
|
40
|
+
"graphql_in_process(graph: #{graphs.first.name.inspect})). Tag the example graphql: :live " \
|
|
41
|
+
"for the app's own client."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect = "#<#{self.class} #{@mode.inspect}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Install `mode` for one example — the example's mode, which is what
|
|
49
|
+
# a graph no helper named runs against.
|
|
50
|
+
#
|
|
51
|
+
# Only the rspec hook installs, once per example after reset!. A
|
|
52
|
+
# helper doesn't: what a helper says is ONE graph's stand-in, written
|
|
53
|
+
# to the table by override!, so no helper can reset another graph's —
|
|
54
|
+
# which is how two graphs run in two modes in one example.
|
|
55
|
+
def install(mode)
|
|
56
|
+
@mode = mode
|
|
57
|
+
@clients = {}
|
|
58
|
+
@context = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Back to no mode: every module resolves its own client again.
|
|
62
|
+
def reset!
|
|
63
|
+
@mode = nil
|
|
64
|
+
@clients = nil
|
|
65
|
+
@context = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether an example is running under a mode — what tells suite setup
|
|
69
|
+
# apart from an example changing it out from under itself.
|
|
70
|
+
def installed? = !@mode.nil?
|
|
71
|
+
|
|
72
|
+
# Whether this example's stand-ins are already built — which is what
|
|
73
|
+
# makes a config setting they were built FROM too late to change.
|
|
74
|
+
def built? = !(@clients.nil? || @clients.empty?)
|
|
75
|
+
|
|
76
|
+
# The GraphQL context every stand-in runs with: this example's, else
|
|
77
|
+
# the suite baseline. graphql_context writes it, and the stand-ins
|
|
78
|
+
# already built take it in place — a :wire example's are built before
|
|
79
|
+
# the example body runs.
|
|
80
|
+
def context = @context || GraphWeaver::Testing.config.context
|
|
81
|
+
|
|
82
|
+
def context=(values)
|
|
83
|
+
@context = values
|
|
84
|
+
@clients&.each_value { |client| client.context = values if client.respond_to?(:context=) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `client` stands in for every graph in `graphs`, in place of the one
|
|
88
|
+
# the mode would build. This is a helper called in an example saying
|
|
89
|
+
# what the modules it names run against.
|
|
90
|
+
def override!(client, graphs)
|
|
91
|
+
graphs.each { |graph| @clients[graph&.name] = client }
|
|
92
|
+
client
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The stand-in for `mod`, or nil when there is nothing to stand in for.
|
|
96
|
+
def for(mod)
|
|
97
|
+
return unless @mode
|
|
98
|
+
# :wire takes no client slot: it serves the resolvers at the
|
|
99
|
+
# endpoint each client already posts to — the transport you ship,
|
|
100
|
+
# running unchanged, is the whole point
|
|
101
|
+
return if @mode == :wire
|
|
102
|
+
# :live is the app's own clients, untouched — so with nothing
|
|
103
|
+
# standing in there is nothing to look up
|
|
104
|
+
return if @mode == :live && !built?
|
|
105
|
+
|
|
106
|
+
# a helper's entry wins whatever the example's mode is, and :live
|
|
107
|
+
# builds nothing of its own, so an untagged example's other graphs
|
|
108
|
+
# still resolve their own clients
|
|
109
|
+
standin(graph_for!(mod))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The stand-in `graph`'s modules run against under the installed mode,
|
|
113
|
+
# built once per example. :wire reaches it too — its clients sit
|
|
114
|
+
# behind the served endpoints rather than in the client slot, but they
|
|
115
|
+
# are the same objects graphql_context has to reach.
|
|
116
|
+
#
|
|
117
|
+
# `mode` is how a helper under a :wire tag asks for the client its own
|
|
118
|
+
# name means rather than the one :wire would have picked.
|
|
119
|
+
def standin(graph, mode = @mode)
|
|
120
|
+
@clients[graph&.name] ||= client_for(mode, graph)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The client `mode` runs `graph` against — the one answer to "what
|
|
124
|
+
# does this tag mean", asked per module here and once per example by
|
|
125
|
+
# the rspec hook. nil for the two modes that take no client slot:
|
|
126
|
+
# :live is the app's own clients, untouched, and :wire serves the
|
|
127
|
+
# resolvers at the endpoint each client already posts to.
|
|
128
|
+
def client_for(mode, graph = app_graph)
|
|
129
|
+
config = GraphWeaver::Testing.config
|
|
130
|
+
case mode
|
|
131
|
+
when :fake
|
|
132
|
+
GraphWeaver::Testing::FakeClient.new(schema: config.reference_schema!(graph),
|
|
133
|
+
registry: graph&.registry)
|
|
134
|
+
when :in_process
|
|
135
|
+
GraphWeaver::InProcess.new(config.schema_class!(graph), context:)
|
|
136
|
+
when :router
|
|
137
|
+
router = config.built_router(graph)
|
|
138
|
+
router.context = context
|
|
139
|
+
# a router is built once per supergraph, so it has to be told
|
|
140
|
+
# where this example starts — the trace, and any faked subgraph's
|
|
141
|
+
# fabricated data. Once per router, not once per graph: two
|
|
142
|
+
# graphs naming one supergraph share it, and resetting again when
|
|
143
|
+
# the second's first module resolved wiped what the first had
|
|
144
|
+
# already accumulated, mid-example.
|
|
145
|
+
router.reset! unless @clients&.value?(router)
|
|
146
|
+
router
|
|
147
|
+
when :wire
|
|
148
|
+
# what sits behind the wire is decided the way the other tags
|
|
149
|
+
# already decide it, per graph — the most faithful thing that
|
|
150
|
+
# graph has, in the order the other tags rank them
|
|
151
|
+
client_for(wire_mode(config, graph), graph)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# What :wire serves for `graph`: its router when it is in a composed
|
|
156
|
+
# supergraph, its live schema class when it has one, else a fake of
|
|
157
|
+
# its schema — which is what an app that is a pure client of someone
|
|
158
|
+
# else's API has, and the only mode it could be.
|
|
159
|
+
#
|
|
160
|
+
# The one candidate the other modes have and :wire doesn't is
|
|
161
|
+
# GraphWeaver.client's own schema: reading it introspects the very
|
|
162
|
+
# endpoint :wire is about to stub, so it is a refusal here rather than
|
|
163
|
+
# a request into a stub that doesn't exist yet.
|
|
164
|
+
def wire_mode(config, graph)
|
|
165
|
+
return :router if config.supergraph?(graph)
|
|
166
|
+
return :in_process if config.schema_class?(graph)
|
|
167
|
+
return :fake if config.schema || graph&.named_schema?
|
|
168
|
+
|
|
169
|
+
raise GraphWeaver::Error, ":wire serves your schema at the endpoint your client posts " \
|
|
170
|
+
"to, and #{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to " \
|
|
171
|
+
"serve — no live GraphQL::Schema class, no composed supergraph, and no type " \
|
|
172
|
+
"information (nothing at #{GraphWeaver.schema_path}, and " \
|
|
173
|
+
"GraphWeaver::Testing.config.schema is unset). Your client's own schema can't stand " \
|
|
174
|
+
"in here: reading it introspects the endpoint :wire has stubbed. Commit a dump " \
|
|
175
|
+
"(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The graph a mode builds for when no module named one: this app's
|
|
179
|
+
# only graph. With several the honest answer varies per module, so
|
|
180
|
+
# there is no app-wide one and each module resolves its own.
|
|
181
|
+
def app_graph
|
|
182
|
+
graphs = GraphWeaver.graphs
|
|
183
|
+
graphs.first if graphs.one?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# What GraphWeaver.client holds while a mode is installed, or nil for
|
|
187
|
+
# the modes that leave the app's own there (:live, and :wire, which
|
|
188
|
+
# serves at each client's endpoint instead).
|
|
189
|
+
#
|
|
190
|
+
# One graph has one answer, so the app slot holds the same stand-in
|
|
191
|
+
# its modules resolve. With several there is none — and leaving the
|
|
192
|
+
# app's real client in the slot let a stray GraphWeaver.client.execute
|
|
193
|
+
# reach the production endpoint from an example whose tag promised no
|
|
194
|
+
# request, so the slot refuses by name instead.
|
|
195
|
+
def app_client
|
|
196
|
+
return if @mode.nil? || @mode == :live || @mode == :wire
|
|
197
|
+
|
|
198
|
+
graph = app_graph
|
|
199
|
+
graph ? standin(graph) : NoAppClient.new(@mode)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The graphs a helper stands in for: the one `graph:` names, else the
|
|
203
|
+
# ones `schema` names, else this app's only graph. A helper that
|
|
204
|
+
# reaches no module is the silent pass this slot exists to stop, so
|
|
205
|
+
# nothing to reach is a refusal — and `advice` is what THIS helper
|
|
206
|
+
# does once it knows which graph.
|
|
207
|
+
#
|
|
208
|
+
# `graph:` leads because a graph's name is its identity everywhere
|
|
209
|
+
# else in the gem, and it is the only spelling that reaches every
|
|
210
|
+
# graph: `schema` is matched by object identity, which a graph whose
|
|
211
|
+
# schema is a dump has nothing to match with.
|
|
212
|
+
def targets!(helper, schema, advice, graph: nil)
|
|
213
|
+
return [graph!(helper, graph)] if graph
|
|
214
|
+
|
|
215
|
+
named = named_graphs(schema)
|
|
216
|
+
return named if named.any?
|
|
217
|
+
|
|
218
|
+
graphs = GraphWeaver.graphs
|
|
219
|
+
return graphs if graphs.one?
|
|
220
|
+
|
|
221
|
+
raise GraphWeaver::Error, "#{helper} stands in for the modules of one graph, and " \
|
|
222
|
+
"#{schema ? "#{schema} names none of this app's graphs" : "this app has #{graphs.size}"} " \
|
|
223
|
+
"(#{declared_names}) — say which: #{helper}(graph: #{graphs.first.name.inspect}). #{advice}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# The graph `graph:` names. Its name, not its schema: that is what
|
|
229
|
+
# `rake graph_weaver:graphs` prints, what codegen bakes into a
|
|
230
|
+
# module's GRAPH, and the one handle a dump-backed graph has.
|
|
231
|
+
def graph!(helper, name)
|
|
232
|
+
found = GraphWeaver.graphs.find { |graph| graph.name == name }
|
|
233
|
+
return found if found
|
|
234
|
+
|
|
235
|
+
near = Util.did_you_mean(GraphWeaver.graphs.map { |graph| graph.name.to_s }, name.to_s)
|
|
236
|
+
raise GraphWeaver::Error, "#{helper}(graph: #{name.inspect}) names none of this app's " \
|
|
237
|
+
"graphs (#{declared_names})#{" — did you mean #{near.to_sym.inspect}?" if near}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The graphs `schema` names: a schema class is matched against what
|
|
241
|
+
# each graph runs in-process, which is the only thing that ties a
|
|
242
|
+
# class to a graph. A graph named by a dump can't be named this way,
|
|
243
|
+
# and correctly isn't.
|
|
244
|
+
def named_graphs(schema)
|
|
245
|
+
return [] unless schema
|
|
246
|
+
|
|
247
|
+
GraphWeaver.graphs.select { |graph| graph.live_schema.equal?(schema) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def declared_names = GraphWeaver.graphs.map { |graph| graph.name.inspect }.join(", ")
|
|
251
|
+
|
|
252
|
+
# The graph `mod` was generated from, by the name codegen baked in.
|
|
253
|
+
# An app with several graphs and a module that names none was
|
|
254
|
+
# generated before its graph was declared, or by an older release —
|
|
255
|
+
# and guessing would fake one schema's shapes at another's module.
|
|
256
|
+
def graph_for!(mod)
|
|
257
|
+
graphs = GraphWeaver.graphs
|
|
258
|
+
return graphs.first if graphs.one?
|
|
259
|
+
|
|
260
|
+
name = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
261
|
+
found = graphs.find { |graph| graph.name == name }
|
|
262
|
+
return found if found
|
|
263
|
+
|
|
264
|
+
# Two doors produce a module, so the fix has two spellings: a file
|
|
265
|
+
# gets its GRAPH back by being regenerated, and a GraphWeaver.parse
|
|
266
|
+
# module — which generates no file, so regenerating cannot reach it
|
|
267
|
+
# — is told where it is parsed.
|
|
268
|
+
raise GraphWeaver::Error, "#{mod} doesn't say which of this app's graphs " \
|
|
269
|
+
"(#{declared_names}) it was generated from, so #{@mode.inspect} has nothing to run " \
|
|
270
|
+
"it against — regenerate it (rake graph_weaver:generate), or, if it came from " \
|
|
271
|
+
"GraphWeaver.parse, say which there (graph: #{graphs.first.name.inspect})."
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
require_relative "../internal"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Internal
|
|
10
|
+
# Which generated props no code in the app reads — the over-fetch that
|
|
11
|
+
# collects when a template stops using a field and nobody edits the
|
|
12
|
+
# .graphql. graphql-client catches it at runtime by masking the data it
|
|
13
|
+
# didn't declare; the structs are checked in here, so it can be recovered
|
|
14
|
+
# without running anything.
|
|
15
|
+
#
|
|
16
|
+
# Name-based on purpose. The generated structs say which props exist; one
|
|
17
|
+
# sweep of the app's own source says which names it mentions. That makes
|
|
18
|
+
# this a lint rather than a proof — #report says so in its own footer,
|
|
19
|
+
# because a finding is a prompt to look, never a verdict.
|
|
20
|
+
class Unused
|
|
21
|
+
# Where a prop shows up when it is READ: a method call, a pattern-match
|
|
22
|
+
# or hash key, a symbol, a string. A bare word in prose matches none of
|
|
23
|
+
# them, which is what keeps comments and locals out.
|
|
24
|
+
READ = /[.:"']([a-z_]\w*)|\b([a-z_]\w*):/
|
|
25
|
+
# Hand a struct to one of these and every prop is read at once, by a
|
|
26
|
+
# call that names none of them. Caught where the sink line carries the
|
|
27
|
+
# module's own name, or a local a line above assigned from it.
|
|
28
|
+
SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
|
|
29
|
+
# `result = PersonQuery.execute!(...)` — the local a response lands in.
|
|
30
|
+
# Following one is what lets the sink be on the NEXT line, which is how
|
|
31
|
+
# anyone actually writes a controller. Excludes == and =~.
|
|
32
|
+
ASSIGN = /\b([a-z_]\w*)\s*=[^=~]/
|
|
33
|
+
# A graphql-ruby TYPE class NAMES every field the server offers, as
|
|
34
|
+
# `field :sku` and as a resolver method — which is the server answering,
|
|
35
|
+
# not this app reading a prop back. Without this an app that serves the
|
|
36
|
+
# graph it consumes (graphql_in_process) marks every prop read, and the
|
|
37
|
+
# task reports nothing however much it over-fetches.
|
|
38
|
+
#
|
|
39
|
+
# Type kinds only: GraphQL::Schema::Resolver and ::Mutation hold
|
|
40
|
+
# application logic — in a BFF that is exactly where an upstream graph
|
|
41
|
+
# gets read — and skipping those files lost every read in them. Both
|
|
42
|
+
# spellings, because graphql-ruby's own generator emits the app-owned
|
|
43
|
+
# base class (`< Types::BaseObject`), not the gem's.
|
|
44
|
+
TYPE_KINDS = "Object|Interface|Union|Enum|Scalar|InputObject"
|
|
45
|
+
SCHEMA = /^[ \t]*(?:class \w+ < (?:GraphQL::Schema::|Types::Base)(?:#{TYPE_KINDS})\b|include GraphQL::Schema::Interface\b)/
|
|
46
|
+
# What the sweep can read. A prop read from anywhere else — a .vue, a
|
|
47
|
+
# .json.erb's sibling JS — is a blind spot, and the footer says so.
|
|
48
|
+
# .rake and .builder are Ruby too.
|
|
49
|
+
EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
|
|
50
|
+
# Directories that hold no app source. "generated" covers both a graph's
|
|
51
|
+
# own output under the convention and a spec/generated fixture dir; a
|
|
52
|
+
# graph that writes somewhere else is pruned by #outputs.
|
|
53
|
+
SKIP = Set["vendor", "node_modules", "tmp", "log", "generated"].freeze
|
|
54
|
+
# enough of the quoted line to judge it by, without wrapping a terminal
|
|
55
|
+
SNIPPET = 100
|
|
56
|
+
|
|
57
|
+
# Measured against real corpora (actionview, activesupport, graphql and
|
|
58
|
+
# six Rails gems swept together): half to two thirds of genuinely unread
|
|
59
|
+
# selections go unreported, rising with corpus size. Saying so is the
|
|
60
|
+
# difference between a lint and a number somebody trusts.
|
|
61
|
+
FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
|
|
62
|
+
"as\nused the moment anything says it. It can't see a prop reached by public_send, or a " \
|
|
63
|
+
"read\nin a file type it doesn't sweep (#{EXTENSIONS.join(", ")}). On a real app half to " \
|
|
64
|
+
"two\nthirds of genuinely unread selections go unreported; silence is the safe direction."
|
|
65
|
+
|
|
66
|
+
# query: the .graphql that selected it. struct/prop: where it landed.
|
|
67
|
+
# wire: how the query spells that prop, when it differs.
|
|
68
|
+
Selection = Struct.new(:query, :module_name, :struct, :prop, :wire) do
|
|
69
|
+
# The GraphQL-side name, which is what you go and delete: the struct's
|
|
70
|
+
# own name is the response key, so `Person.birthday` reads the way the
|
|
71
|
+
# query does — and a camelCase field, an alias or a reserved rename
|
|
72
|
+
# reads the way the query spells it, not the way the prop does.
|
|
73
|
+
def coordinate = "#{struct.name.split("::").last}.#{wire || prop}"
|
|
74
|
+
|
|
75
|
+
# …and the Ruby side, so the report is greppable both ways.
|
|
76
|
+
def constant = "#{struct.name}##{prop}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Why a module's props were all counted read, and on what evidence. Via
|
|
80
|
+
# is the local the value was standing in when it reached the serializer,
|
|
81
|
+
# nil when the sink line named the module itself.
|
|
82
|
+
Excuse = Struct.new(:path, :number, :source, :via) do
|
|
83
|
+
def reason
|
|
84
|
+
where = "handed whole to a serializer at #{path}:#{number}"
|
|
85
|
+
via ? "#{where}, as `#{via}`" : where
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# What one pass over the files answers.
|
|
90
|
+
Sweep = Struct.new(:names, :wholly_used, :files)
|
|
91
|
+
|
|
92
|
+
# paths: the directories to sweep, defaulting to the whole root. Narrowed
|
|
93
|
+
# here rather than by the caller, so an empty PATHS= sweeps everything
|
|
94
|
+
# instead of nothing — nothing would report every prop unread.
|
|
95
|
+
def initialize(graphs: GraphWeaver.graphs, paths: nil)
|
|
96
|
+
@graphs = graphs
|
|
97
|
+
given = Array(paths).map { |path| path.to_s.strip }.reject(&:empty?)
|
|
98
|
+
@roots = (given.empty? ? ["."] : given).map { |path| Util.resolve(path) }
|
|
99
|
+
# A root that isn't there sweeps nothing, and sweeping nothing reports
|
|
100
|
+
# every prop unread — under STRICT, a red build demanding you delete
|
|
101
|
+
# fields you use. `0 files swept` was the only tell, printed beneath
|
|
102
|
+
# the accusations.
|
|
103
|
+
missing = @roots.reject { |root| Dir.exist?(root) }
|
|
104
|
+
return if missing.empty?
|
|
105
|
+
|
|
106
|
+
raise GraphWeaver::Error,
|
|
107
|
+
"no directory at #{missing.map { |root| Util.relative(root) }.join(", ")} — " \
|
|
108
|
+
"PATHS= names directories under #{GraphWeaver.root}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every selection nothing reads, grouped the way the report prints them.
|
|
112
|
+
def findings
|
|
113
|
+
@findings ||= selections
|
|
114
|
+
.reject { |selection| read?(selection) }
|
|
115
|
+
.sort_by { |selection| [Util.relative(selection.query), selection.coordinate] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def report
|
|
119
|
+
# The evidence, not just the verdict: name-matching a serializer call
|
|
120
|
+
# is the mushiest thing here, and a suppression that was wrong should
|
|
121
|
+
# be obvious at a glance rather than silently eating the report.
|
|
122
|
+
lines = wholly_used.flat_map do |name, excuse|
|
|
123
|
+
["#{name}: every prop counted as read — #{excuse.reason}", " #{excuse.source[0, SNIPPET]}"]
|
|
124
|
+
end
|
|
125
|
+
lines += findings.map do |selection|
|
|
126
|
+
"#{Util.relative(selection.query)}: #{selection.coordinate} — selected, never read " \
|
|
127
|
+
"(#{selection.constant})"
|
|
128
|
+
end
|
|
129
|
+
# a run that checked nothing would report "0 unread" whatever the
|
|
130
|
+
# queries said, which is worse than saying so
|
|
131
|
+
lines << nothing_loaded if selections.empty?
|
|
132
|
+
[*lines, "", summary, "", FOOTER].join("\n")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# What the summary counts, so the task can phrase its own STRICT abort.
|
|
136
|
+
def summary
|
|
137
|
+
"#{selections.size} selections, #{findings.size} unread — " \
|
|
138
|
+
"#{count(selections.map(&:query).uniq.size, "query", "queries")}, " \
|
|
139
|
+
"#{count(swept, "file", "files")} swept under #{where}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def count(number, one, many) = "#{number} #{(number == 1) ? one : many}"
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def read?(selection)
|
|
147
|
+
wholly_used.key?(selection.module_name) || names.include?(selection.prop.to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def names = sweep.names
|
|
151
|
+
def wholly_used = sweep.wholly_used
|
|
152
|
+
def swept = sweep.files
|
|
153
|
+
|
|
154
|
+
# One pass over the files for all three answers — the names anything
|
|
155
|
+
# reads, the modules something serializes whole, and how many files that
|
|
156
|
+
# took. Per-prop searching is what makes a tool like this too slow to run.
|
|
157
|
+
def sweep
|
|
158
|
+
@sweep ||= begin
|
|
159
|
+
read = Set.new
|
|
160
|
+
whole = {}
|
|
161
|
+
short = selections.to_h { |selection| [selection.module_name, selection.module_name.split("::").last] }
|
|
162
|
+
files.each do |path|
|
|
163
|
+
# scrub: a stray non-UTF-8 byte in a template is not a reason to
|
|
164
|
+
# refuse to lint the other 500 files
|
|
165
|
+
body = File.read(path).scrub
|
|
166
|
+
next if SCHEMA.match?(body)
|
|
167
|
+
|
|
168
|
+
body.scan(READ) { |method, key| read << (method || key) }
|
|
169
|
+
next unless SINKS.match?(body)
|
|
170
|
+
|
|
171
|
+
# Both substring checks before walking the lines: `to_h` is in
|
|
172
|
+
# most files and a query module's name is in almost none, so this
|
|
173
|
+
# is what keeps the line pass off the other 95%.
|
|
174
|
+
candidates = short.reject { |name, base| whole.key?(name) || !body.include?(base) }
|
|
175
|
+
next if candidates.empty?
|
|
176
|
+
|
|
177
|
+
# ONE line has to carry the module — itself, or a local a line
|
|
178
|
+
# above assigned from it. Anywhere-in-the-file was the first cut
|
|
179
|
+
# and it suppressed this gem's whole report: a doc comment naming
|
|
180
|
+
# PersonQuery three hundred lines above an unrelated to_h counted
|
|
181
|
+
# as serializing it. Following the local is what the line rule
|
|
182
|
+
# missed, and it is the shape every Rails controller has:
|
|
183
|
+
# `result = Q.execute!(...)`, then `render json: result.person`.
|
|
184
|
+
locals = Hash.new { |hash, key| hash[key] = [] }
|
|
185
|
+
body.each_line.with_index(1) do |line, number|
|
|
186
|
+
candidates.each do |name, base|
|
|
187
|
+
locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
|
|
188
|
+
end
|
|
189
|
+
next unless SINKS.match?(line)
|
|
190
|
+
|
|
191
|
+
candidates.each do |name, base|
|
|
192
|
+
# the line naming the module is the better evidence; the local
|
|
193
|
+
# is what it falls back to
|
|
194
|
+
via = locals[name].find { |local| line.match?(/\b#{Regexp.escape(local)}\b/) } \
|
|
195
|
+
unless line.include?(base)
|
|
196
|
+
next unless via || line.include?(base)
|
|
197
|
+
|
|
198
|
+
whole[name] ||= Excuse.new(Util.relative(path), number, line.strip, via)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
Sweep.new(read, whole, files.size)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Every generated prop, per query file. Query-driven like everything
|
|
207
|
+
# else: a struct exists because a query selected it.
|
|
208
|
+
def selections
|
|
209
|
+
@selections ||= @graphs.flat_map do |graph|
|
|
210
|
+
Util.query_files(graph.queries).flat_map do |path|
|
|
211
|
+
source = File.read(path)
|
|
212
|
+
name = graph.generated_names(path, source).first
|
|
213
|
+
next [] unless Object.const_defined?(name)
|
|
214
|
+
|
|
215
|
+
result = Object.const_get(name)
|
|
216
|
+
next [] unless result.const_defined?(:Result, false)
|
|
217
|
+
|
|
218
|
+
words = source.scan(/[A-Za-z_]\w*/).uniq
|
|
219
|
+
props(result.const_get(:Result, false))
|
|
220
|
+
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(words, prop)) }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# How the query spells a prop, when that isn't the prop's own name — a
|
|
226
|
+
# camelCase field, an alias, a reserved rename. Read back off the query
|
|
227
|
+
# text rather than derived from the prop, since no rule inverts an
|
|
228
|
+
# alias; nil when the query spells it the same way, which is most of
|
|
229
|
+
# the time.
|
|
230
|
+
def wire_word(words, prop)
|
|
231
|
+
words.find { |word| word != prop.to_s && GraphWeaver::Codegen.prop_name(word) == prop.to_s }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Nested structs are nested constants, so the props of a whole response
|
|
235
|
+
# are one walk down. Reported flat: a parent nothing reads makes its
|
|
236
|
+
# children unread too, and saying both is the honest count.
|
|
237
|
+
def props(struct, found = [])
|
|
238
|
+
struct.props.each_key { |prop| found << [struct, prop] }
|
|
239
|
+
struct.constants(false).each do |const|
|
|
240
|
+
nested = struct.const_get(const, false)
|
|
241
|
+
props(nested, found) if nested.is_a?(Class) && nested < T::Struct
|
|
242
|
+
end
|
|
243
|
+
found
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def files
|
|
247
|
+
@files ||= @roots.flat_map { |root| collect(root, []) }.uniq.sort
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Pruned as it walks rather than globbed and filtered: node_modules is
|
|
251
|
+
# the directory you most want never to descend into.
|
|
252
|
+
def collect(dir, found)
|
|
253
|
+
Dir.children(dir).sort.each do |entry|
|
|
254
|
+
path = File.join(dir, entry)
|
|
255
|
+
# lstat, so a symlinked directory can't loop the walk
|
|
256
|
+
stat = File.lstat(path)
|
|
257
|
+
if stat.directory?
|
|
258
|
+
collect(path, found) unless skip_dir?(entry, path)
|
|
259
|
+
elsif stat.file? && EXTENSIONS.include?(File.extname(entry))
|
|
260
|
+
found << path
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
found
|
|
264
|
+
rescue SystemCallError
|
|
265
|
+
found
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
|
|
269
|
+
|
|
270
|
+
# The generated directories a name check can't catch: a graph that sets
|
|
271
|
+
# `output` somewhere of its own.
|
|
272
|
+
def outputs
|
|
273
|
+
@outputs ||= (Util.generated_dirs + @graphs.map(&:output))
|
|
274
|
+
.flat_map { |pattern| Dir.glob(Util.resolve(pattern), File::FNM_PATHNAME) }
|
|
275
|
+
.to_set
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def where
|
|
279
|
+
@roots.map { |root| (root == GraphWeaver.root) ? "." : Util.relative(root) }.join(", ")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def nothing_loaded
|
|
283
|
+
"nothing to check: no generated module is loaded for any query here (rake graph_weaver:generate)"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -50,6 +50,10 @@ class GraphWeaver::Internal::Values
|
|
|
50
50
|
# What Codegen.scalar reports for a scalar nobody registered
|
|
51
51
|
UNREGISTERED = "T.untyped"
|
|
52
52
|
|
|
53
|
+
# What JSON can hold. Anything else a pin offers is a Ruby object the
|
|
54
|
+
# registration has to serialize before it can stand in for a response.
|
|
55
|
+
WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
|
|
56
|
+
|
|
53
57
|
# The fallback, for a scalar nobody registered: its prop is T.untyped, so
|
|
54
58
|
# anything holds and a plausible shape beats a placeholder.
|
|
55
59
|
NAMED_SHAPES = {
|
|
@@ -71,7 +75,13 @@ class GraphWeaver::Internal::Values
|
|
|
71
75
|
# pin is resolved against the query, which is the fake's job. Left unsaid
|
|
72
76
|
# they are the suite's, so a scalar only the app can write for is
|
|
73
77
|
# fabricable from the cassette anonymizer too.
|
|
74
|
-
|
|
78
|
+
# schema: which server is being faked. registry: the registrations the
|
|
79
|
+
# values have to satisfy — a Money registered for one graph is not a Money
|
|
80
|
+
# for the next, and only a caller holding the graph can say which. Left
|
|
81
|
+
# unsaid they are read back off the schema, which is the answer for every
|
|
82
|
+
# app with one graph running a live class.
|
|
83
|
+
def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil)
|
|
84
|
+
@registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
|
|
75
85
|
@rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
|
|
76
86
|
@pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
|
|
77
87
|
@style = resolve_style(values)
|
|
@@ -86,7 +96,9 @@ class GraphWeaver::Internal::Values
|
|
|
86
96
|
# alone. at: where the walk is ("reader.orders.0.total"), for that refusal;
|
|
87
97
|
# a walk that doesn't track one leaves it unsaid.
|
|
88
98
|
def scalar(type_name, field_name, coordinate = nil, at: nil)
|
|
89
|
-
|
|
99
|
+
if @pins.key?(type_name)
|
|
100
|
+
return wire(type_name, GraphWeaver::Internal::Overrides.resolve(@pins[type_name], rng), coordinate)
|
|
101
|
+
end
|
|
90
102
|
|
|
91
103
|
registered, shape = resolve(type_name, coordinate)
|
|
92
104
|
prop = underscore(field_name)
|
|
@@ -117,12 +129,31 @@ class GraphWeaver::Internal::Values
|
|
|
117
129
|
# the plain-notation string BigDecimal() reads and #to_s("F") writes
|
|
118
130
|
when :decimal then format("%.2f", @rng.rand(0.0..10_000.0))
|
|
119
131
|
when :date then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
|
|
120
|
-
|
|
132
|
+
# a fraction a quarter of the time: a JS/Apollo server writes milliseconds
|
|
133
|
+
# on every timestamp, and whole seconds alone can't show a lossy round trip
|
|
134
|
+
when :time then fake_time.then { |t| t.iso8601(t.subsec.zero? ? 0 : 3) }
|
|
121
135
|
when :unregistered then "#{type_name}-#{@sequence += 1}" # nobody registered it: prop is T.untyped
|
|
122
136
|
else unfakeable!(type_name, field_name, registered, coordinate, at)
|
|
123
137
|
end
|
|
124
138
|
end
|
|
125
139
|
|
|
140
|
+
# What the wire would carry for a pinned scalar. A pin may be written as the
|
|
141
|
+
# Ruby object an app reads back — a Time, a Money — and the registration
|
|
142
|
+
# says what the server sends for one; a value JSON can already hold stands
|
|
143
|
+
# as written. Shared with the object-pin door, so both read a pin the same
|
|
144
|
+
# way.
|
|
145
|
+
def wire(type_name, value, coordinate = nil)
|
|
146
|
+
return value if WIRE.any? { |klass| value.is_a?(klass) }
|
|
147
|
+
|
|
148
|
+
serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
|
|
149
|
+
return serialized if WIRE.any? { |klass| serialized.is_a?(klass) }
|
|
150
|
+
|
|
151
|
+
article = GraphWeaver::Internal::Util.article(value.class.to_s)
|
|
152
|
+
raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
|
|
153
|
+
"is what the wire carries — register_scalar(#{type_name.inspect}) has no serialize: that can run " \
|
|
154
|
+
"against a value (a Proc builds source), so write the pin as the value the server would send"
|
|
155
|
+
end
|
|
156
|
+
|
|
126
157
|
# same original id => same fake id, so relationships survive anonymization
|
|
127
158
|
def mapped_id(original)
|
|
128
159
|
@id_map[original] ||= (@sequence += 1).to_s
|
|
@@ -130,11 +161,16 @@ class GraphWeaver::Internal::Values
|
|
|
130
161
|
|
|
131
162
|
private
|
|
132
163
|
|
|
164
|
+
def fake_time
|
|
165
|
+
usec = @rng.rand(0..3).zero? ? @rng.rand(1..999) * 1_000 : 0
|
|
166
|
+
Time.at(1_600_000_000 + @rng.rand(0..100_000_000), usec).utc
|
|
167
|
+
end
|
|
168
|
+
|
|
133
169
|
# The registration in play and the shape it wants, memoized per scalar (or
|
|
134
170
|
# per coordinate, where a field-level registration overrides it).
|
|
135
171
|
def resolve(type_name, coordinate)
|
|
136
172
|
@resolved[coordinate || type_name] ||= begin
|
|
137
|
-
registered =
|
|
173
|
+
registered = @registry.scalar(type_name, coordinate)
|
|
138
174
|
[registered, shape_of(type_name, registered.type)]
|
|
139
175
|
end
|
|
140
176
|
end
|