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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +43 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +67 -6
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +23 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. 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
- "#{prop}: #{redact.detail(prop, e.message)}",
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
- "#{prop}: #{redact.detail(prop, e.message)}",
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 fallback under — every list it
14
- # doesn't name.
15
- LIST_SIZE_DEFAULT = "default"
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
- return unless list_size.is_a?(Hash)
55
-
56
- list_size.each do |key, value|
57
- unless value.is_a?(Integer) || value.is_a?(Range)
58
- raise GraphWeaver::Error, "list_size: #{key.to_s.inspect} must be an Integer or a " \
59
- "Range — how long an unbounded list is — got #{value.inspect}"
60
- end
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
- validate_field_key!(schema, key.to_s, "list_size: key")
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
- return if type.fields.key?(field_name)
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
- bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
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(graph_for!(mod))
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, ":wire serves your schema at the endpoint your client posts " \
169
- "to, and #{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to " \
170
- "serve — no live GraphQL::Schema class, no composed supergraph, and no type " \
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). Your client's own schema can't stand " \
173
- "in here: reading it introspects the endpoint :wire has stubbed. Commit a dump " \
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 local a response lands in.
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. Excludes == and =~.
32
- ASSIGN = /\b([a-z_]\w*)\s*=[^=~]/
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?(/\b#{Regexp.escape(local)}\b/) } \
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
- words = source.scan(/[A-Za-z_]\w*/).uniq
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(words, prop)) }
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
- # text rather than derived from the prop, since no rule inverts an
234
- # alias; nil when the query spells it the same way, which is most of
235
- # the time.
236
- def wire_word(words, prop)
237
- words.find { |word| word != prop.to_s && GraphWeaver::Codegen.prop_name(word) == prop.to_s }
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
- def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil)
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 the app default executes against,
247
- # when it runs in-process — a Client wrapping one, or the class in
248
- # the slot bare. nil for every network client. Not memoized: in dev
249
- # the class object is replaced on reload.
250
- def live_schema
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 summary is info, the wire is
24
- # debug.** This is the only GraphWeaver line at info, so a production log
25
- # gets one per operation and nothing that could carry PII; turning
26
- # GraphWeaver.logger up to debug adds the query, the variables and the
27
- # response *beneath* it rather than repeating it.
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 this method name is EXECUTE_EVENT's first half, both ways.
37
- def execute(event)
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
- GraphWeaver::Internal::Log.log(:info) do
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
- # What ran: the operation, prefixed by its graph when the request carried
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 which attempt this was when a Retry is in the stack.
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