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
|
@@ -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
|