graph_weaver 0.7.4 → 0.7.6

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
@@ -69,22 +69,37 @@ module GraphWeaver
69
69
  # neither that nor which values exist. Raised bare so the enclosing
70
70
  # Hints.field brands it with the field.
71
71
  # aliases (register_enum alias:) is wire spelling => the value it reads as.
72
- def self.enum(type, value, aliases = nil)
72
+ # fallback is the Other member, when the registration asked for one.
73
+ def self.enum(type, value, aliases = nil, fallback: nil)
73
74
  value = aliases.fetch(value, value) if aliases
75
+ member = type.try_deserialize(value)
76
+ return member if member
77
+ return absorbed(type, value, fallback) if fallback
74
78
 
75
- type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
79
+ drifted!(type, value, type.values.map(&:serialize), "register_enum fallback: true")
76
80
  end
77
81
 
78
82
  # the same, for an enum mapped onto an app-owned T::Enum, where the wire
79
83
  # table rather than the type knows the accepted values
80
84
  def self.mapped_enum(type, table, value)
81
- table.fetch(value) { drifted!(type, value, table.keys) }
85
+ table.fetch(value) { drifted!(type, value, table.keys, "register_enum fallback:") }
82
86
  end
83
87
 
84
- def self.drifted!(type, value, values)
88
+ # A T::Enum member is a singleton, so Other can't carry the value it
89
+ # swallowed — this line is the only record that anything drifted.
90
+ def self.absorbed(type, value, fallback)
91
+ GraphWeaver::Internal::Log.log(:debug) do
92
+ # the member's bare constant name — a T::Enum member inspects as #<Type::Name>
93
+ "#{type} absorbed #{GraphWeaver::Internal::Redact.shown(value)} into #{fallback.inspect[/::(\w+)>\z/, 1]}"
94
+ end
95
+ fallback
96
+ end
97
+ private_class_method :absorbed
98
+
99
+ def self.drifted!(type, value, values, suggestion)
85
100
  raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
86
101
  "#{values.sort.join(", ")}; a value the server added since you generated " \
87
- "needs a regenerate, or register_enum fallback: to absorb them"
102
+ "needs a regenerate, or #{suggestion} to absorb them"
88
103
  end
89
104
  private_class_method :drifted!
90
105
 
@@ -55,7 +55,7 @@ class GraphWeaver::InProcess
55
55
  payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
56
56
  kind: GraphWeaver::Internal::Wire.kind(query) }
57
57
 
58
- GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
58
+ GraphWeaver::Internal::Log.instrument_request(payload) do
59
59
  perform(query, variables, operation_name)
60
60
  end
61
61
  end
@@ -72,14 +72,12 @@ class GraphWeaver::InProcess
72
72
  "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
73
73
  end
74
74
 
75
- result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
75
+ GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
76
76
  # a copy per query: graphql-ruby writes a resolver's `context[...] =`
77
77
  # into the hash it is handed, and one client serves every request
78
78
  @schema.execute(query, variables:, operation_name:,
79
79
  context: GraphWeaver::Internal::Util.context!(@context).dup)
80
80
  end
81
-
82
- result
83
81
  rescue GraphWeaver::Error
84
82
  raise
85
83
  rescue => e
@@ -24,19 +24,28 @@ 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
31
37
  # rather than T::Enum.deserialize / the wire table directly: both raise a
32
38
  # bare KeyError naming an anonymous module and none of the values they
33
39
  # would have taken.
34
- def self.enum(type, value, aliases = nil)
35
- return value if value.is_a?(type)
36
-
37
- value = aliases.fetch(value, value) if aliases
38
-
39
- type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
40
+ # fallback is the generated Other member (register_enum fallback: true).
41
+ # It is the one member input refuses: nothing on the wire means it, so a
42
+ # variable carrying it would send a value the server never declared.
43
+ def self.enum(type, value, aliases = nil, fallback: nil)
44
+ member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
45
+ return member if member && !member.equal?(fallback)
46
+
47
+ accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
48
+ member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
40
49
  end
41
50
 
42
51
  # A list element's index, prepended when something inside it refused —
@@ -67,6 +76,25 @@ module GraphWeaver
67
76
  table.fetch(value) { invalid_enum!(type, value, table.keys) }
68
77
  end
69
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
+
70
98
  # Names the input field a coercion refused — a scalar's coercer, an
71
99
  # enum's, or a nested input's — since the complaint underneath is about
72
100
  # the value alone. A nested error that already named a field keeps its
@@ -80,7 +108,7 @@ module GraphWeaver
80
108
  raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
81
109
 
82
110
  raise GraphWeaver::InputError.new(
83
- "#{prop}: #{redact.detail(prop, e.message)}",
111
+ "#{field.label}: #{redact.detail(prop, e.message)}",
84
112
  kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
85
113
  # #value is the value AT #path: this layer owns it only when nothing
86
114
  # inner named a field (so a missing one stays valueless, as it is)
@@ -90,7 +118,7 @@ module GraphWeaver
90
118
  rescue StandardError => e
91
119
  redact = GraphWeaver::Internal::Redact
92
120
  raise GraphWeaver::InputError.new(
93
- "#{prop}: #{redact.detail(prop, e.message)}",
121
+ "#{field.label}: #{redact.detail(prop, e.message)}",
94
122
  kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
95
123
  coordinate: field.coordinate, value: redact.value(prop, raw),
96
124
  details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
@@ -110,6 +138,18 @@ module GraphWeaver
110
138
  end
111
139
  private_class_method :invalid_enum!
112
140
 
141
+ # The fallback member is a landing pad for drift, not a value — so it is
142
+ # refused by name rather than listed among the ones you could have meant.
143
+ def self.unsendable_enum!(member, accepted)
144
+ # a T::Enum member inspects as #<Type::Name>
145
+ raise GraphWeaver::Internal::Refusal.brand(
146
+ KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
147
+ "there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
148
+ :not_a_member, members: accepted.sort,
149
+ )
150
+ end
151
+ private_class_method :unsendable_enum!
152
+
113
153
  def self.included(base)
114
154
  base.extend(ClassMethods)
115
155
  end
@@ -269,7 +309,7 @@ module GraphWeaver
269
309
  field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
270
310
  type = field&.type || T::Utils.coerce(info[:type]).to_s
271
311
  return GraphWeaver::InputError.new(
272
- "#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
312
+ "#{field&.label || prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
273
313
  kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
274
314
  value: GraphWeaver::Internal::Redact.value(prop, value),
275
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
@@ -34,9 +34,6 @@ module GraphWeaver
34
34
  # goes through the same check *and refuses at construction* — a swapped
35
35
  # pair fails there rather than as a mystery three fetches later.
36
36
  module Subgraphs
37
- # how many coordinates a message names before it says "and N more"
38
- SAMPLE = 5
39
-
40
37
  # answer this subgraph with fabricated data rather than refusing
41
38
  FAKE = :fake
42
39
 
@@ -117,15 +114,9 @@ module GraphWeaver
117
114
  return schema if gaps.empty?
118
115
 
119
116
  raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
120
- "#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
117
+ "#{schema.name || schema.inspect}, which doesn't define #{Util.sample(gaps)} — the supergraph " \
121
118
  "says #{name} resolves them. Did two entries get swapped?"
122
119
  end
123
-
124
- def sample(list)
125
- return list.join(", ") if list.size <= SAMPLE
126
-
127
- "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
128
- end
129
120
  end
130
121
  end
131
122
  end
@@ -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
@@ -47,6 +54,11 @@ module GraphWeaver
47
54
  # .json.erb's sibling JS — is a blind spot, and the footer says so.
48
55
  # .rake and .builder are Ruby too.
49
56
  EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
57
+ # Ruby that carries no extension to recognise it by. In a non-Rails
58
+ # project the entry points live here, so skipping them skipped the
59
+ # files that read the query.
60
+ SCRIPT_DIRS = Set["bin", "exe"].freeze
61
+ RUBY_SHEBANG = /\A#!.*\bruby\b/
50
62
  # Directories that hold no app source. "generated" covers both a graph's
51
63
  # own output under the convention and a spec/generated fixture dir; a
52
64
  # graph that writes somewhere else is pruned by #outputs.
@@ -60,8 +72,9 @@ module GraphWeaver
60
72
  # difference between a lint and a number somebody trusts.
61
73
  FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
62
74
  "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."
75
+ "read\nin a file type it doesn't sweep #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
76
+ "extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
77
+ "two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
65
78
 
66
79
  # query: the .graphql that selected it. struct/prop: where it landed.
67
80
  # wire: how the query spells that prop, when it differs.
@@ -183,6 +196,7 @@ module GraphWeaver
183
196
  # `result = Q.execute!(...)`, then `render json: result.person`.
184
197
  locals = Hash.new { |hash, key| hash[key] = [] }
185
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)
186
200
  candidates.each do |name, base|
187
201
  locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
188
202
  end
@@ -191,7 +205,7 @@ module GraphWeaver
191
205
  candidates.each do |name, base|
192
206
  # the line naming the module is the better evidence; the local
193
207
  # is what it falls back to
194
- 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/) } \
195
209
  unless line.include?(base)
196
210
  next unless via || line.include?(base)
197
211
 
@@ -215,20 +229,31 @@ module GraphWeaver
215
229
  result = Object.const_get(name)
216
230
  next [] unless result.const_defined?(:Result, false)
217
231
 
218
- words = source.scan(/[A-Za-z_]\w*/).uniq
232
+ keys = response_keys(GraphQL.parse(source).definitions)
219
233
  props(result.const_get(:Result, false))
220
- .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)) }
221
235
  end
222
236
  end
223
237
  end
224
238
 
225
239
  # How the query spells a prop, when that isn't the prop's own name — a
226
240
  # 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 }
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
232
257
  end
233
258
 
234
259
  # Nested structs are nested constants, so the props of a whole response
@@ -244,19 +269,22 @@ module GraphWeaver
244
269
  end
245
270
 
246
271
  def files
247
- @files ||= @roots.flat_map { |root| collect(root, []) }.uniq.sort
272
+ @files ||= @roots
273
+ .flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
274
+ .uniq.sort
248
275
  end
249
276
 
250
277
  # 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)
278
+ # the directory you most want never to descend into. scripts says we are
279
+ # inside bin/ or exe/, which the walk knows and a path doesn't.
280
+ def collect(dir, found, scripts)
253
281
  Dir.children(dir).sort.each do |entry|
254
282
  path = File.join(dir, entry)
255
283
  # lstat, so a symlinked directory can't loop the walk
256
284
  stat = File.lstat(path)
257
285
  if stat.directory?
258
- collect(path, found) unless skip_dir?(entry, path)
259
- elsif stat.file? && EXTENSIONS.include?(File.extname(entry))
286
+ collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
287
+ elsif stat.file? && ruby?(path, entry, scripts)
260
288
  found << path
261
289
  end
262
290
  end
@@ -265,6 +293,22 @@ module GraphWeaver
265
293
  found
266
294
  end
267
295
 
296
+ # An extension names most of it. A file with none is Ruby if it sits
297
+ # under bin/ or exe/ — that is what those directories are for — or if
298
+ # its first line says so.
299
+ def ruby?(path, entry, scripts)
300
+ return true if EXTENSIONS.include?(File.extname(entry))
301
+ return false unless File.extname(entry).empty?
302
+
303
+ scripts || shebang?(path)
304
+ end
305
+
306
+ def shebang?(path)
307
+ File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
308
+ rescue SystemCallError, ArgumentError
309
+ false
310
+ end
311
+
268
312
  def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
269
313
 
270
314
  # The generated directories a name check can't catch: a graph that sets
@@ -57,6 +57,11 @@ class GraphWeaver::Internal::Values
57
57
  # registration says the server writes "12.5".
58
58
  WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
59
59
 
60
+ # Whether a value is already one of those. At a leaf it means the registry's
61
+ # serializer has nothing to do; at a composite position (FakeClient) it means
62
+ # the pin stands as written.
63
+ def self.wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
64
+
60
65
  # The fallback, for a scalar nobody registered: its prop is T.untyped, so
61
66
  # anything holds and a plausible shape beats a placeholder.
62
67
  NAMED_SHAPES = {
@@ -83,7 +88,12 @@ class GraphWeaver::Internal::Values
83
88
  # for the next, and only a caller holding the graph can say which. Left
84
89
  # unsaid they are read back off the schema, which is the answer for every
85
90
  # app with one graph running a live class.
86
- 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
87
97
  @registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
88
98
  @rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
89
99
  @pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
@@ -146,10 +156,10 @@ class GraphWeaver::Internal::Values
146
156
  # as written. Shared with the object-pin door, so both read a pin the same
147
157
  # way.
148
158
  def wire(type_name, value, coordinate = nil)
149
- return value if WIRE.any? { |klass| value.is_a?(klass) }
159
+ return value if self.class.wire?(value)
150
160
 
151
161
  serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
152
- return serialized if WIRE.any? { |klass| serialized.is_a?(klass) }
162
+ return serialized if self.class.wire?(serialized)
153
163
 
154
164
  article = GraphWeaver::Internal::Util.article(value.class.to_s)
155
165
  raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
@@ -197,12 +207,19 @@ class GraphWeaver::Internal::Values
197
207
  # for — `Money.parse` accepts what its author decided it accepts — and
198
208
  # guessing hands the generated cast a placeholder, which fails deep inside
199
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.
200
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."
201
221
  raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
202
- "it deserializes into #{registered.type}, and only you know what wire value that accepts. " \
203
- "Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
204
- "overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
205
- "GraphWeaver::Testing.config.overrides."
222
+ "it deserializes into #{registered.type}, and only you know what wire value that accepts. #{advice}"
206
223
  end
207
224
 
208
225
  # :faker is an explicit ask — fail loudly when the gem is missing; auto