graph_weaver 0.7.5 → 0.7.7
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.lock +43 -43
- data/docs/errors.md +7 -3
- data/docs/generated_modules.md +66 -16
- data/docs/getting_started.md +39 -19
- data/docs/logging.md +79 -35
- data/docs/migrating.md +11 -4
- data/docs/scalars.md +12 -3
- data/docs/testing.md +75 -13
- data/docs/upgrading.md +43 -3
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +20 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +15 -3
- data/lib/graph_weaver/codegen/emit.rb +7 -6
- data/lib/graph_weaver/codegen/enum_type.rb +26 -2
- data/lib/graph_weaver/codegen/nodes.rb +21 -2
- data/lib/graph_weaver/codegen.rb +106 -54
- data/lib/graph_weaver/graph.rb +67 -6
- data/lib/graph_weaver/in_process.rb +1 -1
- data/lib/graph_weaver/input_struct.rb +29 -4
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +30 -11
- data/lib/graph_weaver/internal/values.rb +17 -5
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +89 -53
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +48 -8
- data/lib/graph_weaver/railtie.rb +250 -11
- data/lib/graph_weaver/rspec.rb +41 -17
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +22 -9
- data/lib/graph_weaver/tasks.rb +71 -44
- data/lib/graph_weaver/testing/fake_client.rb +24 -21
- data/lib/graph_weaver/testing.rb +27 -7
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +76 -43
- metadata +2 -2
|
@@ -24,7 +24,13 @@ module GraphWeaver
|
|
|
24
24
|
# is the schema's name for the slot ("PetFilter.species") and type its
|
|
25
25
|
# spelling of what goes there ("[Float!]!"), so a refusal can say where
|
|
26
26
|
# it happened, and in whose vocabulary, without reflecting at runtime.
|
|
27
|
-
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
|
|
27
|
+
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type) do
|
|
28
|
+
# How a message names this field. The prop is what you type in Ruby, so
|
|
29
|
+
# it leads; the wire name is what you grep the .graphql for, so it comes
|
|
30
|
+
# along where the two differ. Nothing structured reads this — #path,
|
|
31
|
+
# #field and #coordinate are the schema's spelling either way.
|
|
32
|
+
def label = (wire == prop.to_s) ? prop.to_s : "#{prop} (#{wire})"
|
|
33
|
+
end
|
|
28
34
|
|
|
29
35
|
# An enum reaching the library as input — an execute kwarg or an input
|
|
30
36
|
# field — as the member or its wire value. Generated code calls these
|
|
@@ -70,6 +76,25 @@ module GraphWeaver
|
|
|
70
76
|
table.fetch(value) { invalid_enum!(type, value, table.keys) }
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
# The way back out: a member => the wire value it sends as. Generation
|
|
80
|
+
# makes that table total, so a miss is a T::Enum that grew a member since
|
|
81
|
+
# — and Hash#fetch's KeyError named the anonymous table rather than the
|
|
82
|
+
# member or anything it could have sent. Also the result side's `as_json`,
|
|
83
|
+
# where nothing wraps a raised KeyError into a GraphWeaver error at all.
|
|
84
|
+
def self.enum_wire(graphql_name, table, member)
|
|
85
|
+
table.fetch(member) do
|
|
86
|
+
raise GraphWeaver::Internal::Refusal.brand(
|
|
87
|
+
GraphWeaver::Error.new(
|
|
88
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
89
|
+
"#{member.inspect[2..-2]} maps onto no #{graphql_name} value, so there is nothing to " \
|
|
90
|
+
"send for it — expected one of: #{table.values.sort.join(", ")}; a member added since " \
|
|
91
|
+
"you generated needs a regenerate",
|
|
92
|
+
),
|
|
93
|
+
:not_a_member, members: table.values.sort,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
73
98
|
# Names the input field a coercion refused — a scalar's coercer, an
|
|
74
99
|
# enum's, or a nested input's — since the complaint underneath is about
|
|
75
100
|
# the value alone. A nested error that already named a field keeps its
|
|
@@ -83,7 +108,7 @@ module GraphWeaver
|
|
|
83
108
|
raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
|
|
84
109
|
|
|
85
110
|
raise GraphWeaver::InputError.new(
|
|
86
|
-
"#{
|
|
111
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
87
112
|
kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
|
|
88
113
|
# #value is the value AT #path: this layer owns it only when nothing
|
|
89
114
|
# inner named a field (so a missing one stays valueless, as it is)
|
|
@@ -93,7 +118,7 @@ module GraphWeaver
|
|
|
93
118
|
rescue StandardError => e
|
|
94
119
|
redact = GraphWeaver::Internal::Redact
|
|
95
120
|
raise GraphWeaver::InputError.new(
|
|
96
|
-
"#{
|
|
121
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
97
122
|
kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
|
|
98
123
|
coordinate: field.coordinate, value: redact.value(prop, raw),
|
|
99
124
|
details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
|
|
@@ -284,7 +309,7 @@ module GraphWeaver
|
|
|
284
309
|
field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
|
|
285
310
|
type = field&.type || T::Utils.coerce(info[:type]).to_s
|
|
286
311
|
return GraphWeaver::InputError.new(
|
|
287
|
-
"#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
312
|
+
"#{field&.label || prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
288
313
|
kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
|
|
289
314
|
value: GraphWeaver::Internal::Redact.value(prop, value),
|
|
290
315
|
details: { type: }, struct: self,
|
|
@@ -10,9 +10,9 @@ module GraphWeaver
|
|
|
10
10
|
# suite-wide ones while the block that set them is still on the stack,
|
|
11
11
|
# and a fake built with its own checks them then.
|
|
12
12
|
module Overrides
|
|
13
|
-
# The key a Hash `list_size:` says its
|
|
14
|
-
# doesn't name.
|
|
15
|
-
|
|
13
|
+
# The key a per-field Hash (`list_size:`, `null_chance:`) says its
|
|
14
|
+
# fallback under — everything it doesn't name.
|
|
15
|
+
DEFAULT_KEY = "default"
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
18
|
# A pin key names something in the schema: a type ("Money",
|
|
@@ -51,16 +51,22 @@ module GraphWeaver
|
|
|
51
51
|
# minus the bare type name: a type says nothing about how long any one
|
|
52
52
|
# of its fields is.
|
|
53
53
|
def validate_list_size!(schema, list_size)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
next if key.to_s == LIST_SIZE_DEFAULT
|
|
54
|
+
reaches = ->(type) { type.list? }
|
|
55
|
+
validate_per_field!(schema, list_size, "list_size",
|
|
56
|
+
reaches:, unreached: "is not a list and has no length to set — name a list field") do |value|
|
|
57
|
+
"an Integer or a Range of them, neither negative — how long an unbounded list is" unless
|
|
58
|
+
length?(value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
# A Hash `null_chance:` is keyed the same way, one nullable field at a
|
|
63
|
+
# time.
|
|
64
|
+
def validate_null_chance!(schema, null_chance)
|
|
65
|
+
reaches = method(:nullable_anywhere?)
|
|
66
|
+
validate_per_field!(schema, null_chance, "null_chance",
|
|
67
|
+
reaches:, unreached: "can never come back null — name a nullable field") do |value|
|
|
68
|
+
"a number from 0 to 1 — how often a nullable field comes back null" unless
|
|
69
|
+
value.is_a?(Numeric) && (0..1).cover?(value)
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
@@ -75,6 +81,83 @@ module GraphWeaver
|
|
|
75
81
|
|
|
76
82
|
private
|
|
77
83
|
|
|
84
|
+
# The two shapes both per-field options take: one value for every
|
|
85
|
+
# field, or a Hash keyed by field — a "Type.field" coordinate or a
|
|
86
|
+
# bare field name — with DEFAULT_KEY for the rest. The block says
|
|
87
|
+
# what a value has to be, in the words the refusal uses, and says it
|
|
88
|
+
# of both shapes: a plain `null_chance: 7` used to sail through and
|
|
89
|
+
# null everything, a plain `list_size: "3"` to die inside the
|
|
90
|
+
# fabricator.
|
|
91
|
+
def validate_per_field!(schema, option, name, reaches:, unreached:)
|
|
92
|
+
unless option.is_a?(Hash)
|
|
93
|
+
refuse_value!(name, nil, option, yield(option))
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
option.each do |key, value|
|
|
98
|
+
refuse_value!(name, key, value, yield(value))
|
|
99
|
+
next if key.to_s == DEFAULT_KEY
|
|
100
|
+
|
|
101
|
+
label = "#{name}: key"
|
|
102
|
+
validate_field_key!(schema, key.to_s, label)
|
|
103
|
+
reaches!(schema, key.to_s, label, reaches, unreached)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A key naming a field the option can never reach is inert, which is
|
|
108
|
+
# the silent green every other key check exists to stop: the
|
|
109
|
+
# fabricator asks `null_chance` at nullable positions only and
|
|
110
|
+
# `list_size` at lists only, so it never looks this key up.
|
|
111
|
+
def reaches!(schema, key, label, reaches, unreached)
|
|
112
|
+
types = field_types(schema, key)
|
|
113
|
+
return if types.empty? || types.any? { |type| reaches.call(type) }
|
|
114
|
+
|
|
115
|
+
spelled = types.map(&:to_type_signature).uniq.sort.join(", ")
|
|
116
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} (#{spelled}) #{unreached}, " \
|
|
117
|
+
"or drop the key"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every field a per-field key names: the one a coordinate points at,
|
|
121
|
+
# or every field of that name in the schema for a bare one.
|
|
122
|
+
def field_types(schema, key)
|
|
123
|
+
type_name, field_name = key.split(".", 2)
|
|
124
|
+
# introspection fields (__typename) are real but absent from #fields
|
|
125
|
+
return [] if (field_name || type_name).start_with?("__")
|
|
126
|
+
return [schema.get_type(type_name).fields.fetch(field_name).type] if field_name
|
|
127
|
+
|
|
128
|
+
schema.types.each_value.filter_map do |type|
|
|
129
|
+
type.fields[type_name]&.type if type.respond_to?(:fields)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether null_chance has a position in this type to reach. The outer
|
|
134
|
+
# wrapper isn't the whole answer: `[Pet]!` is non-null and its ELEMENTS
|
|
135
|
+
# are nullable, which is where the fabricator puts the nulls.
|
|
136
|
+
def nullable_anywhere?(type)
|
|
137
|
+
return true unless type.non_null?
|
|
138
|
+
|
|
139
|
+
inner = type.of_type
|
|
140
|
+
inner.list? && nullable_anywhere?(inner.of_type)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def refuse_value!(name, key, value, wanted)
|
|
144
|
+
return unless wanted
|
|
145
|
+
|
|
146
|
+
raise GraphWeaver::Error, "#{name}:#{" #{key.to_s.inspect}" if key} must be #{wanted} — " \
|
|
147
|
+
"got #{value.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A length is a count the fabricator can build an Array of: Array.new(-1)
|
|
151
|
+
# is "negative array size" out of its guts, and a Range the seeded rng
|
|
152
|
+
# can't sample (endless, or beginless) is worse.
|
|
153
|
+
def length?(value)
|
|
154
|
+
case value
|
|
155
|
+
when Integer then !value.negative?
|
|
156
|
+
when Range then [value.begin, value.end].all? { |edge| edge.is_a?(Integer) && !edge.negative? }
|
|
157
|
+
else false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
78
161
|
# A proc taking anything else can't be called at fabrication time,
|
|
79
162
|
# and the ArgumentError it would raise there names no pin.
|
|
80
163
|
def validate_arity!(key, value)
|
|
@@ -113,20 +196,49 @@ module GraphWeaver
|
|
|
113
196
|
known = field_names(schema)
|
|
114
197
|
return if known.include?(type_name)
|
|
115
198
|
|
|
199
|
+
# a type name reads like a reasonable key here — `null_chance: {
|
|
200
|
+
# "Person" => 1.0 }` looks like "null the whole subtree" — and
|
|
201
|
+
# did_you_mean sent it to the nearest FIELD ('person') instead of
|
|
202
|
+
# saying these options are keyed by field
|
|
203
|
+
named = schema.get_type(type_name)
|
|
204
|
+
type_key!(label, key, named) if named
|
|
116
205
|
bad!(label, key, "matches no field in this schema", known, type_name)
|
|
117
206
|
end
|
|
118
207
|
|
|
119
208
|
coordinate!(schema, label, key, type_name, field_name)
|
|
120
209
|
end
|
|
121
210
|
|
|
211
|
+
def type_key!(label, key, type)
|
|
212
|
+
reach = type.respond_to?(:fields) ? "#{type.graphql_name}.<field>".inspect : "a \"Type.field\" coordinate"
|
|
213
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} names " \
|
|
214
|
+
"#{type.kind.name.downcase.tr("_", " ")} #{type.graphql_name}, and a key here names " \
|
|
215
|
+
"one field — #{reach}, or a bare field name"
|
|
216
|
+
end
|
|
217
|
+
|
|
122
218
|
def coordinate!(schema, label, key, type_name, field_name)
|
|
123
219
|
type = schema.get_type(type_name)
|
|
124
220
|
unless type.respond_to?(:fields)
|
|
125
221
|
bad!(label, key, "names no object type in this schema", schema.types.keys, type_name)
|
|
126
222
|
end
|
|
127
|
-
|
|
223
|
+
unless type.fields.key?(field_name)
|
|
224
|
+
bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
concrete!(schema, label, key, type, field_name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The abstract-type refusal, in the coordinate form. An interface
|
|
231
|
+
# declares the field, so "Named.name" reads as a key that must work
|
|
232
|
+
# — and it matches nothing: the walk picks a member before it builds
|
|
233
|
+
# a coordinate, so every key it looks up is "Person.name".
|
|
234
|
+
def concrete!(schema, label, key, type, field_name)
|
|
235
|
+
return unless type.kind.abstract?
|
|
128
236
|
|
|
129
|
-
|
|
237
|
+
members = schema.possible_types(type)
|
|
238
|
+
.map { |member| "#{member.graphql_name}.#{field_name}".inspect }.sort
|
|
239
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} names #{type.kind.name.downcase} " \
|
|
240
|
+
"#{type.graphql_name}, and a fake only ever holds a concrete type: name the " \
|
|
241
|
+
"concrete type — #{members.join(", ")}"
|
|
130
242
|
end
|
|
131
243
|
|
|
132
244
|
# A type pin says what every value of that type is, and the fake only
|
|
@@ -97,15 +97,20 @@ module GraphWeaver
|
|
|
97
97
|
# :wire takes no client slot: it serves the resolvers at the
|
|
98
98
|
# endpoint each client already posts to — the transport you ship,
|
|
99
99
|
# running unchanged, is the whole point
|
|
100
|
-
return if @mode == :wire
|
|
100
|
+
return if @mode == :wire && GraphWeaver.graphs.all?(&:client_url)
|
|
101
101
|
# :live is the app's own clients, untouched — so with nothing
|
|
102
102
|
# standing in there is nothing to look up
|
|
103
103
|
return if @mode == :live && !built?
|
|
104
104
|
|
|
105
|
+
graph = graph_for!(mod)
|
|
106
|
+
# a graph whose client posts to no url has no wire to be served at,
|
|
107
|
+
# so :wire serves it here instead — the same pick, one hop shorter
|
|
108
|
+
return if @mode == :wire && graph&.client_url
|
|
109
|
+
|
|
105
110
|
# a helper's entry wins whatever the example's mode is, and :live
|
|
106
111
|
# builds nothing of its own, so an untagged example's other graphs
|
|
107
112
|
# still resolve their own clients
|
|
108
|
-
standin(
|
|
113
|
+
standin(graph)
|
|
109
114
|
end
|
|
110
115
|
|
|
111
116
|
# The stand-in `graph`'s modules run against under the installed mode,
|
|
@@ -165,12 +170,29 @@ module GraphWeaver
|
|
|
165
170
|
return :in_process if config.schema_class?(graph)
|
|
166
171
|
return :fake if config.schema || graph&.named_schema?
|
|
167
172
|
|
|
168
|
-
raise GraphWeaver::Error,
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
raise GraphWeaver::Error, nothing_to_serve(graph)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Two graphs are in different states here, and the served one's
|
|
177
|
+
# sentences are all false for the other: a graph whose client posts
|
|
178
|
+
# nowhere has no endpoint to stub, no stub of ours to introspect, and
|
|
179
|
+
# no url to refresh a dump from — what it is missing is a schema.
|
|
180
|
+
def nothing_to_serve(graph)
|
|
181
|
+
missing = "no live GraphQL::Schema class, no composed supergraph, and no type " \
|
|
171
182
|
"information (nothing at #{GraphWeaver.schema_path}, and " \
|
|
172
|
-
"GraphWeaver::Testing.config.schema is unset)
|
|
173
|
-
|
|
183
|
+
"GraphWeaver::Testing.config.schema is unset)"
|
|
184
|
+
if graph&.name && !graph.client_url
|
|
185
|
+
return ":wire has no endpoint for graph #{graph.name.inspect} — its client posts to " \
|
|
186
|
+
"none, so its modules run above the wire, against the most faithful stand-in the " \
|
|
187
|
+
"graph has. It has #{missing}. Name one where the graph is declared: " \
|
|
188
|
+
"GraphWeaver.graph(#{graph.name.inspect}) { schema -> { MySchema } }, or a dump " \
|
|
189
|
+
"(schema \"schema.graphql\"). Or tag the example graphql: :live."
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
":wire serves your schema at the endpoint your client posts to, and " \
|
|
193
|
+
"#{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to serve — " \
|
|
194
|
+
"#{missing}. Your client's own schema can't stand in here: reading it introspects " \
|
|
195
|
+
"the endpoint :wire has stubbed. Commit a dump " \
|
|
174
196
|
"(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
|
|
175
197
|
end
|
|
176
198
|
|
|
@@ -26,10 +26,17 @@ module GraphWeaver
|
|
|
26
26
|
# call that names none of them. Caught where the sink line carries the
|
|
27
27
|
# module's own name, or a local a line above assigned from it.
|
|
28
28
|
SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
|
|
29
|
-
# `result = PersonQuery.execute!(...)` — the
|
|
29
|
+
# `result = PersonQuery.execute!(...)` — the name a response lands in.
|
|
30
30
|
# Following one is what lets the sink be on the NEXT line, which is how
|
|
31
|
-
# anyone actually writes a controller.
|
|
32
|
-
|
|
31
|
+
# anyone actually writes a controller. The `@` is part of the capture:
|
|
32
|
+
# it is what says the name outlives the method. Excludes == and =~.
|
|
33
|
+
ASSIGN = /(@?\b[a-z_]\w*)\s*=[^=~]/
|
|
34
|
+
# Where a plain local stops standing for the module it was assigned
|
|
35
|
+
# from: the next method is a new scope, and a block param there that
|
|
36
|
+
# happens to share the name holds someone else's value. An ivar crosses
|
|
37
|
+
# it — `before_action` loading `@result` for the action to render is
|
|
38
|
+
# the shape every Rails controller has.
|
|
39
|
+
SCOPE = /^[ \t]*def\s/
|
|
33
40
|
# A graphql-ruby TYPE class NAMES every field the server offers, as
|
|
34
41
|
# `field :sku` and as a resolver method — which is the server answering,
|
|
35
42
|
# not this app reading a prop back. Without this an app that serves the
|
|
@@ -189,6 +196,7 @@ module GraphWeaver
|
|
|
189
196
|
# `result = Q.execute!(...)`, then `render json: result.person`.
|
|
190
197
|
locals = Hash.new { |hash, key| hash[key] = [] }
|
|
191
198
|
body.each_line.with_index(1) do |line, number|
|
|
199
|
+
locals.each_value { |names| names.select! { |n| n.start_with?("@") } } if SCOPE.match?(line)
|
|
192
200
|
candidates.each do |name, base|
|
|
193
201
|
locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
|
|
194
202
|
end
|
|
@@ -197,7 +205,7 @@ module GraphWeaver
|
|
|
197
205
|
candidates.each do |name, base|
|
|
198
206
|
# the line naming the module is the better evidence; the local
|
|
199
207
|
# is what it falls back to
|
|
200
|
-
via = locals[name].find { |local| line.match?(
|
|
208
|
+
via = locals[name].find { |local| line.match?(/(?<![\w@])#{Regexp.escape(local)}\b/) } \
|
|
201
209
|
unless line.include?(base)
|
|
202
210
|
next unless via || line.include?(base)
|
|
203
211
|
|
|
@@ -221,20 +229,31 @@ module GraphWeaver
|
|
|
221
229
|
result = Object.const_get(name)
|
|
222
230
|
next [] unless result.const_defined?(:Result, false)
|
|
223
231
|
|
|
224
|
-
|
|
232
|
+
keys = response_keys(GraphQL.parse(source).definitions)
|
|
225
233
|
props(result.const_get(:Result, false))
|
|
226
|
-
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(
|
|
234
|
+
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(keys, prop)) }
|
|
227
235
|
end
|
|
228
236
|
end
|
|
229
237
|
end
|
|
230
238
|
|
|
231
239
|
# How the query spells a prop, when that isn't the prop's own name — a
|
|
232
240
|
# camelCase field, an alias, a reserved rename. Read back off the query
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
241
|
+
# rather than derived from the prop, since no rule inverts an alias; nil
|
|
242
|
+
# when the query spells it the same way, which is most of the time.
|
|
243
|
+
def wire_word(keys, prop)
|
|
244
|
+
keys.find { |key| key != prop.to_s && GraphWeaver::Codegen.prop_name(key) == prop.to_s }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Every response key the document asks for. The coordinate is the
|
|
248
|
+
# selection to go and delete, so it can only be one of these: scanning
|
|
249
|
+
# the file's text let `$id: ID!` — or a comment — name a field the query
|
|
250
|
+
# never selected.
|
|
251
|
+
def response_keys(nodes, found = [])
|
|
252
|
+
nodes.each do |node|
|
|
253
|
+
found << (node.alias || node.name) if node.is_a?(GraphQL::Language::Nodes::Field)
|
|
254
|
+
response_keys(node.selections, found) if node.respond_to?(:selections)
|
|
255
|
+
end
|
|
256
|
+
found
|
|
238
257
|
end
|
|
239
258
|
|
|
240
259
|
# Nested structs are nested constants, so the props of a whole response
|
|
@@ -88,7 +88,12 @@ class GraphWeaver::Internal::Values
|
|
|
88
88
|
# for the next, and only a caller holding the graph can say which. Left
|
|
89
89
|
# unsaid they are read back off the schema, which is the answer for every
|
|
90
90
|
# app with one graph running a live class.
|
|
91
|
-
|
|
91
|
+
# pin_advice: how to pin, in the words of the door the caller came in by —
|
|
92
|
+
# a callable taking the scalar's name. Left unsaid it is the three doors
|
|
93
|
+
# onto a fake, which is where this is reached from unless something says
|
|
94
|
+
# otherwise (check_scalars! runs outside all three).
|
|
95
|
+
def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil, pin_advice: nil)
|
|
96
|
+
@pin_advice = pin_advice
|
|
92
97
|
@registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
|
|
93
98
|
@rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
|
|
94
99
|
@pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
|
|
@@ -202,12 +207,19 @@ class GraphWeaver::Internal::Values
|
|
|
202
207
|
# for — `Money.parse` accepts what its author decided it accepts — and
|
|
203
208
|
# guessing hands the generated cast a placeholder, which fails deep inside
|
|
204
209
|
# from_h blaming the codec.
|
|
210
|
+
#
|
|
211
|
+
# Each door onto a fake spells a pin differently and this can't know which
|
|
212
|
+
# you came in by, so the advice names all three — unless the caller said,
|
|
213
|
+
# in which case it is the one that works there.
|
|
205
214
|
def unfakeable!(type_name, field_name, registered, coordinate, at)
|
|
215
|
+
type = type_name.inspect
|
|
216
|
+
advice = @pin_advice&.call(type_name) ||
|
|
217
|
+
"Pin the type (#{type}) or just this field (#{(coordinate || field_name).inspect}), " \
|
|
218
|
+
"wherever the fake is built — graphql_fake(#{type} => ...) in an rspec example, " \
|
|
219
|
+
"FakeClient.new(#{type} => ...) outside one, or GraphWeaver::Testing.config.overrides " \
|
|
220
|
+
"for the whole suite."
|
|
206
221
|
raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
|
|
207
|
-
"it deserializes into #{registered.type}, and only you know what wire value that accepts. "
|
|
208
|
-
"Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
|
|
209
|
-
"overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
|
|
210
|
-
"GraphWeaver::Testing.config.overrides."
|
|
222
|
+
"it deserializes into #{registered.type}, and only you know what wire value that accepts. #{advice}"
|
|
211
223
|
end
|
|
212
224
|
|
|
213
225
|
# :faker is an explicit ask — fail loudly when the gem is missing; auto
|
|
@@ -192,6 +192,23 @@ module GraphWeaver
|
|
|
192
192
|
GraphWeaver.generated_paths | extra
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# The generated directories this process has to require ITSELF:
|
|
196
|
+
# generated_dirs, less any tree the Railtie left to Zeitwerk. Those
|
|
197
|
+
# load on first reference and reload with the app, and requiring them
|
|
198
|
+
# is the one thing an autoloadable layout exists to avoid.
|
|
199
|
+
def required_dirs
|
|
200
|
+
generated_dirs.reject { |dir| left_to_zeitwerk?(dir) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# by name, not spelled: the railtie is Rails-only and `typed: ignore`,
|
|
204
|
+
# so sorbet has no constant to resolve. Outside Rails there is no
|
|
205
|
+
# autoloader and the answer is always no.
|
|
206
|
+
def left_to_zeitwerk?(path)
|
|
207
|
+
return false unless GraphWeaver.const_defined?(:Railtie, false)
|
|
208
|
+
|
|
209
|
+
GraphWeaver.const_get(:Railtie, false).left_to_zeitwerk?(path)
|
|
210
|
+
end
|
|
211
|
+
|
|
195
212
|
# Every query document under these directories, sorted — the files
|
|
196
213
|
# generate!, verify_generated!, check_queries and load_queries! read.
|
|
197
214
|
def query_files(paths = GraphWeaver.queries_paths)
|
|
@@ -243,14 +260,14 @@ module GraphWeaver
|
|
|
243
260
|
"or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
244
261
|
end
|
|
245
262
|
|
|
246
|
-
# The graphql-ruby schema class
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
# the class object is
|
|
250
|
-
|
|
263
|
+
# The graphql-ruby schema class a client executes against, when it
|
|
264
|
+
# runs in-process — a Client wrapping one, or the class in the slot
|
|
265
|
+
# bare. nil for every network client. Defaults to the app's own, and
|
|
266
|
+
# a graph passes its client. Not memoized: in dev the class object is
|
|
267
|
+
# replaced on reload.
|
|
268
|
+
def live_schema(client = GraphWeaver.client)
|
|
251
269
|
# through #transport, not #schema: a url client's #schema
|
|
252
270
|
# introspects, so asking it would answer over the network
|
|
253
|
-
client = GraphWeaver.client
|
|
254
271
|
target = client.is_a?(Client) ? client.transport : client
|
|
255
272
|
target = target.schema if target.is_a?(InProcess)
|
|
256
273
|
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
@@ -14,17 +14,20 @@ module GraphWeaver
|
|
|
14
14
|
# GraphWeaver PersonQuery (12.3ms) ok
|
|
15
15
|
# GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
|
|
16
16
|
# GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
|
|
17
|
+
# GraphWeaver PersonQuery (44.0ms) failed GraphWeaver::CastError
|
|
17
18
|
# GraphWeaver billing/InvoicesQuery (12.3ms) ok
|
|
18
19
|
#
|
|
19
20
|
# Attached by the railtie wherever ActiveSupport is, and fed by the
|
|
20
21
|
# instrumenter it sets. Requires ActiveSupport — `require` this yourself
|
|
21
22
|
# only if you subscribe by hand.
|
|
22
23
|
#
|
|
23
|
-
# **One rule decides which line you get: the
|
|
24
|
-
# debug.**
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
24
|
+
# **One rule decides which line you get: the operation is info, the
|
|
25
|
+
# attempt is debug.** The info line is one CALL of a generated module, so
|
|
26
|
+
# it says what the caller got — a cast that raised reads `failed`, and a
|
|
27
|
+
# test mode's stand-in gets a line like every other client. A production
|
|
28
|
+
# log gets one per operation and nothing that could carry PII; turning
|
|
29
|
+
# GraphWeaver.logger up to debug adds each attempt, with the url, the HTTP
|
|
30
|
+
# status and which retry it was, beneath it.
|
|
28
31
|
#
|
|
29
32
|
# It writes through GraphWeaver.logger rather than Rails.logger, so
|
|
30
33
|
# `GraphWeaver.logger = nil` — the documented way to silence the gem —
|
|
@@ -33,17 +36,10 @@ module GraphWeaver
|
|
|
33
36
|
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
34
37
|
# attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
|
|
35
38
|
# ActiveSupport::Subscriber#call dispatches on the name up to the first
|
|
36
|
-
# dot — so
|
|
37
|
-
def
|
|
38
|
-
payload = event.payload
|
|
39
|
+
# dot — so these method names are the events' first halves, both ways.
|
|
40
|
+
def operation(event) = write(:info, event)
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
# duration_ms is the instrumenter's own measurement; event.duration
|
|
42
|
-
# covers a subscriber attached to something that didn't set it
|
|
43
|
-
ms = payload[:duration_ms] || event.duration
|
|
44
|
-
"GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
|
|
45
|
-
end
|
|
46
|
-
end
|
|
42
|
+
def execute(event) = write(:debug, event)
|
|
47
43
|
|
|
48
44
|
# GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
|
|
49
45
|
# subscriber whose logger is nil, which is what makes the gem's own
|
|
@@ -52,7 +48,20 @@ module GraphWeaver
|
|
|
52
48
|
|
|
53
49
|
private
|
|
54
50
|
|
|
55
|
-
#
|
|
51
|
+
# One shape for both, so the attempt beneath an operation reads as the
|
|
52
|
+
# same line rather than a second format to learn.
|
|
53
|
+
def write(level, event)
|
|
54
|
+
payload = event.payload
|
|
55
|
+
|
|
56
|
+
GraphWeaver::Internal::Log.log(level) do
|
|
57
|
+
# duration_ms is the instrumenter's own measurement; event.duration
|
|
58
|
+
# covers a subscriber attached to something that didn't set it
|
|
59
|
+
ms = payload[:duration_ms] || event.duration
|
|
60
|
+
"GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What ran: the operation, prefixed by its graph when the payload names
|
|
56
65
|
# one — an app with several graphs reads `billing/InvoicesQuery` without
|
|
57
66
|
# a second line shape to learn, and one with a single graph never sees it.
|
|
58
67
|
def subject(payload)
|
|
@@ -61,7 +70,8 @@ module GraphWeaver
|
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
# status, then whatever narrows it: the error class, the reason an alert
|
|
64
|
-
# groups by, and
|
|
73
|
+
# groups by, and — on a debug attempt line — which try this was. The last
|
|
74
|
+
# two are attempt facts, so only the debug line ever carries them.
|
|
65
75
|
def outcome(payload)
|
|
66
76
|
parts = [payload[:status], payload[:error]]
|
|
67
77
|
# the GraphQL code, or the HTTP status where the request never got one
|