graph_weaver 0.7.3 → 0.7.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +5 -2
  5. data/docs/federation.md +3 -2
  6. data/docs/generated_modules.md +176 -22
  7. data/docs/getting_started.md +174 -14
  8. data/docs/i18n.md +4 -4
  9. data/docs/migrating.md +119 -0
  10. data/docs/scalars.md +161 -35
  11. data/docs/testing.md +24 -3
  12. data/docs/upgrading.md +51 -5
  13. data/examples/github/generated/star_mutation.rb +24 -2
  14. data/examples/github/generated/stargazers_query.rb +61 -5
  15. data/examples/github/generated/starred_query.rb +33 -3
  16. data/lib/generators/graph_weaver/install_generator.rb +32 -3
  17. data/lib/graph_weaver/client.rb +23 -0
  18. data/lib/graph_weaver/codegen/aliases.rb +23 -2
  19. data/lib/graph_weaver/codegen/emit.rb +35 -16
  20. data/lib/graph_weaver/codegen/enum_type.rb +149 -19
  21. data/lib/graph_weaver/codegen/nodes.rb +72 -37
  22. data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
  23. data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
  24. data/lib/graph_weaver/codegen.rb +259 -106
  25. data/lib/graph_weaver/coerce.rb +25 -6
  26. data/lib/graph_weaver/federation.rb +1 -6
  27. data/lib/graph_weaver/graph.rb +4 -1
  28. data/lib/graph_weaver/hints.rb +23 -5
  29. data/lib/graph_weaver/in_process.rb +1 -3
  30. data/lib/graph_weaver/input_struct.rb +31 -10
  31. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  32. data/lib/graph_weaver/internal/unused.rb +32 -7
  33. data/lib/graph_weaver/internal/values.rb +12 -4
  34. data/lib/graph_weaver/internal.rb +84 -0
  35. data/lib/graph_weaver/logging.rb +26 -29
  36. data/lib/graph_weaver/query_module.rb +20 -5
  37. data/lib/graph_weaver/railtie.rb +7 -2
  38. data/lib/graph_weaver/rspec.rb +0 -1
  39. data/lib/graph_weaver/schema_loader.rb +7 -8
  40. data/lib/graph_weaver/tasks.rb +60 -5
  41. data/lib/graph_weaver/testing/fake_client.rb +4 -10
  42. data/lib/graph_weaver/testing/router.rb +26 -25
  43. data/lib/graph_weaver/testing.rb +101 -1
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +80 -74
  46. metadata +3 -2
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "date" # Date/DateTime, named below
5
+ require "time" # Time.iso8601
5
6
 
6
7
  require_relative "errors"
7
8
  require_relative "internal/refusal"
@@ -38,19 +39,24 @@ module GraphWeaver
38
39
  # app translates for a user. It defaults to the GraphQL scalar the rule
39
40
  # is named for; generated code passes the schema's own name, so a
40
41
  # `register_scalar("BigInt", Integer)` field refuses as a BigInt.
42
+ #
43
+ # A real number converts to the number the scalar names, and whether it
44
+ # is whole is asked of the value — so a BigDecimal off a decimal column
45
+ # and a Rational are ordinary arguments.
41
46
  def integer(value, scalar = "Int")
42
47
  case value
43
48
  when Integer then value
44
- when Float then whole(value, scalar)
49
+ when Numeric then whole(real(value, scalar), scalar)
45
50
  when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
46
51
  else refuse(value, scalar)
47
52
  end
48
53
  end
49
54
 
55
+ # Float's own precision is what asking for a Float means, so a BigDecimal
56
+ # past it loses digits here rather than being refused.
50
57
  def float(value, scalar = "Float")
51
58
  case value
52
- when Float then finite(value, scalar)
53
- when Integer then finite(value.to_f, scalar)
59
+ when Numeric then finite(real(value, scalar).to_f, scalar)
54
60
  when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
55
61
  else refuse(value, scalar)
56
62
  end
@@ -70,12 +76,16 @@ module GraphWeaver
70
76
  end
71
77
  end
72
78
 
79
+ # A timestamp string is ISO 8601, read by Time.iso8601 — the coercer
80
+ # graphql-ruby's own ISO8601DateTime reads input with, and the only
81
+ # shape its coerce_result writes. Time.parse also reads "Jan 15 2024
82
+ # 10:20", which no server sends and no generated struct should take.
73
83
  def time(value, scalar = "Time")
74
84
  case value
75
85
  when Time then value
76
86
  when DateTime then value.to_time # the same instant in another class
77
87
  when Date then cross(value, scalar, TIME_HINT)
78
- when String then parsing(scalar, value) { Time.parse(value) }
88
+ when String then parsing(scalar, value) { Time.iso8601(value) }
79
89
  else time_like?(value) ? value.to_time : refuse(value, scalar)
80
90
  end
81
91
  end
@@ -194,8 +204,17 @@ module GraphWeaver
194
204
  raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
195
205
  end
196
206
 
207
+ # Complex is the one Numeric that is not a number a scalar can name, and
208
+ # #real? is the predicate for it — no class list to keep up to date.
209
+ def real(value, scalar)
210
+ return value if value.real?
211
+
212
+ refuse(value, scalar)
213
+ end
214
+
197
215
  def whole(value, scalar = "Int")
198
- # Integer(2.5) is 2 — a silent loss where refusing costs nothing
216
+ # Integer(2.5) is 2 — a silent loss where refusing costs nothing. `% 1`
217
+ # is exact for a BigDecimal and a Rational; asking Float would not be.
199
218
  return value.to_i if value.finite? && (value % 1).zero?
200
219
 
201
220
  raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
@@ -236,7 +255,7 @@ module GraphWeaver
236
255
 
237
256
  # the article by the initial, so a registered Ruby class ("an Integer")
238
257
  # reads as well as the scalars ("an Int", "an ID", "a Date")
239
- def expected(scalar) = "expected #{scalar.start_with?(/[AEIOU]/) ? "an" : "a"} #{scalar}"
258
+ def expected(scalar) = "expected #{Internal::Util.article(scalar)} #{scalar}"
240
259
  end
241
260
  end
242
261
  end
@@ -341,15 +341,10 @@ module GraphWeaver
341
341
  *@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
342
342
  end
343
343
 
344
- # how many coordinates the report names before it says "and N more"
345
- SAMPLE = 5
346
- private_constant :SAMPLE
347
-
348
344
  def evidence(coordinates)
349
345
  return "the supergraph attributes nothing to it alone" if coordinates.empty?
350
- return coordinates.join(", ") if coordinates.size <= SAMPLE
351
346
 
352
- "#{coordinates.first(SAMPLE).join(", ")} and #{coordinates.size - SAMPLE} more"
347
+ Internal::Util.sample(coordinates)
353
348
  end
354
349
 
355
350
  def faked_section
@@ -54,12 +54,15 @@ module GraphWeaver
54
54
  # block runs once, at the declaration (GraphBuilder.build reads the
55
55
  # registry there), and every read after replays the module it made.
56
56
  def replay(registry, registration)
57
- call, args, kwargs, block = registration
57
+ call, args, kwargs, block, aliases = registration
58
58
  entry = registry.public_send(call, *args, **kwargs, &block)
59
+ # the block's alias_field lines ran with it, so replay them too
60
+ entry[:aliases].merge!(aliases) if aliases
59
61
  return unless block && call == :extend_type
60
62
 
61
63
  registration[1] = args + [entry[:mixins].last]
62
64
  registration[3] = nil
65
+ registration[4] = entry[:aliases].dup
63
66
  end
64
67
  private :replay
65
68
 
@@ -68,20 +68,38 @@ module GraphWeaver
68
68
  # you generated) rather than a bad value, and T::Enum's own KeyError says
69
69
  # neither that nor which values exist. Raised bare so the enclosing
70
70
  # Hints.field brands it with the field.
71
- def self.enum(type, value)
72
- type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
71
+ # aliases (register_enum alias:) is wire spelling => the value it reads as.
72
+ # fallback is the Other member, when the registration asked for one.
73
+ def self.enum(type, value, aliases = nil, fallback: nil)
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
78
+
79
+ drifted!(type, value, type.values.map(&:serialize), "register_enum fallback: true")
73
80
  end
74
81
 
75
82
  # the same, for an enum mapped onto an app-owned T::Enum, where the wire
76
83
  # table rather than the type knows the accepted values
77
84
  def self.mapped_enum(type, table, value)
78
- table.fetch(value) { drifted!(type, value, table.keys) }
85
+ table.fetch(value) { drifted!(type, value, table.keys, "register_enum fallback:") }
79
86
  end
80
87
 
81
- 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)
82
100
  raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
83
101
  "#{values.sort.join(", ")}; a value the server added since you generated " \
84
- "needs a regenerate, or register_enum fallback: to absorb them"
102
+ "needs a regenerate, or #{suggestion} to absorb them"
85
103
  end
86
104
  private_class_method :drifted!
87
105
 
@@ -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
@@ -21,19 +21,25 @@ module GraphWeaver
21
21
 
22
22
  # serializer/coercer are code-as-data from the generated file; nil
23
23
  # means identity (the wire value passes through untouched). coordinate
24
- # is the schema's name for the slot ("PetFilter.species"), so a refusal
25
- # can say where it happened without reflecting at runtime.
26
- Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate)
24
+ # is the schema's name for the slot ("PetFilter.species") and type its
25
+ # spelling of what goes there ("[Float!]!"), so a refusal can say where
26
+ # it happened, and in whose vocabulary, without reflecting at runtime.
27
+ Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
27
28
 
28
29
  # An enum reaching the library as input — an execute kwarg or an input
29
30
  # field — as the member or its wire value. Generated code calls these
30
31
  # rather than T::Enum.deserialize / the wire table directly: both raise a
31
32
  # bare KeyError naming an anonymous module and none of the values they
32
33
  # would have taken.
33
- def self.enum(type, value)
34
- return value if value.is_a?(type)
35
-
36
- type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
34
+ # fallback is the generated Other member (register_enum fallback: true).
35
+ # It is the one member input refuses: nothing on the wire means it, so a
36
+ # variable carrying it would send a value the server never declared.
37
+ def self.enum(type, value, aliases = nil, fallback: nil)
38
+ member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
39
+ return member if member && !member.equal?(fallback)
40
+
41
+ accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
42
+ member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
37
43
  end
38
44
 
39
45
  # A list element's index, prepended when something inside it refused —
@@ -107,6 +113,18 @@ module GraphWeaver
107
113
  end
108
114
  private_class_method :invalid_enum!
109
115
 
116
+ # The fallback member is a landing pad for drift, not a value — so it is
117
+ # refused by name rather than listed among the ones you could have meant.
118
+ def self.unsendable_enum!(member, accepted)
119
+ # a T::Enum member inspects as #<Type::Name>
120
+ raise GraphWeaver::Internal::Refusal.brand(
121
+ KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
122
+ "there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
123
+ :not_a_member, members: accepted.sort,
124
+ )
125
+ end
126
+ private_class_method :unsendable_enum!
127
+
110
128
  def self.included(base)
111
129
  base.extend(ClassMethods)
112
130
  end
@@ -260,11 +278,14 @@ module GraphWeaver
260
278
  # out blaming the list that held the struct, in sorbet's words.
261
279
  next if T::Utils.coerce(info[:type_object]).recursively_valid?(value)
262
280
 
263
- type = T::Utils.coerce(info[:type]).to_s
281
+ # the SCHEMA's spelling, from the FIELDS row — #details[:type] is
282
+ # what an app translates for a user, and "T::Array[Float]" is the
283
+ # library's vocabulary leaking into theirs
284
+ field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
285
+ type = field&.type || T::Utils.coerce(info[:type]).to_s
264
286
  return GraphWeaver::InputError.new(
265
287
  "#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
266
- kind: :type_mismatch, path: [prop.to_s],
267
- coordinate: T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }&.coordinate,
288
+ kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
268
289
  value: GraphWeaver::Internal::Redact.value(prop, value),
269
290
  details: { type: }, struct: self,
270
291
  )
@@ -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
@@ -47,6 +47,11 @@ module GraphWeaver
47
47
  # .json.erb's sibling JS — is a blind spot, and the footer says so.
48
48
  # .rake and .builder are Ruby too.
49
49
  EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
50
+ # Ruby that carries no extension to recognise it by. In a non-Rails
51
+ # project the entry points live here, so skipping them skipped the
52
+ # files that read the query.
53
+ SCRIPT_DIRS = Set["bin", "exe"].freeze
54
+ RUBY_SHEBANG = /\A#!.*\bruby\b/
50
55
  # Directories that hold no app source. "generated" covers both a graph's
51
56
  # own output under the convention and a spec/generated fixture dir; a
52
57
  # graph that writes somewhere else is pruned by #outputs.
@@ -60,8 +65,9 @@ module GraphWeaver
60
65
  # difference between a lint and a number somebody trusts.
61
66
  FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
62
67
  "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."
68
+ "read\nin a file type it doesn't sweep #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
69
+ "extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
70
+ "two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
65
71
 
66
72
  # query: the .graphql that selected it. struct/prop: where it landed.
67
73
  # wire: how the query spells that prop, when it differs.
@@ -244,19 +250,22 @@ module GraphWeaver
244
250
  end
245
251
 
246
252
  def files
247
- @files ||= @roots.flat_map { |root| collect(root, []) }.uniq.sort
253
+ @files ||= @roots
254
+ .flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
255
+ .uniq.sort
248
256
  end
249
257
 
250
258
  # 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)
259
+ # the directory you most want never to descend into. scripts says we are
260
+ # inside bin/ or exe/, which the walk knows and a path doesn't.
261
+ def collect(dir, found, scripts)
253
262
  Dir.children(dir).sort.each do |entry|
254
263
  path = File.join(dir, entry)
255
264
  # lstat, so a symlinked directory can't loop the walk
256
265
  stat = File.lstat(path)
257
266
  if stat.directory?
258
- collect(path, found) unless skip_dir?(entry, path)
259
- elsif stat.file? && EXTENSIONS.include?(File.extname(entry))
267
+ collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
268
+ elsif stat.file? && ruby?(path, entry, scripts)
260
269
  found << path
261
270
  end
262
271
  end
@@ -265,6 +274,22 @@ module GraphWeaver
265
274
  found
266
275
  end
267
276
 
277
+ # An extension names most of it. A file with none is Ruby if it sits
278
+ # under bin/ or exe/ — that is what those directories are for — or if
279
+ # its first line says so.
280
+ def ruby?(path, entry, scripts)
281
+ return true if EXTENSIONS.include?(File.extname(entry))
282
+ return false unless File.extname(entry).empty?
283
+
284
+ scripts || shebang?(path)
285
+ end
286
+
287
+ def shebang?(path)
288
+ File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
289
+ rescue SystemCallError, ArgumentError
290
+ false
291
+ end
292
+
268
293
  def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
269
294
 
270
295
  # The generated directories a name check can't catch: a graph that sets
@@ -51,8 +51,16 @@ class GraphWeaver::Internal::Values
51
51
  UNREGISTERED = "T.untyped"
52
52
 
53
53
  # What JSON can hold. Anything else a pin offers is a Ruby object the
54
- # registration has to serialize before it can stand in for a response.
55
- WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
54
+ # registration has to serialize before it can stand in for a response
55
+ # Integer and Float, not Numeric, because a BigDecimal is a Numeric that
56
+ # JSON has no spelling for, and taking one as written sent 12.5 where the
57
+ # registration says the server writes "12.5".
58
+ WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
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) }
56
64
 
57
65
  # The fallback, for a scalar nobody registered: its prop is T.untyped, so
58
66
  # anything holds and a plausible shape beats a placeholder.
@@ -143,10 +151,10 @@ class GraphWeaver::Internal::Values
143
151
  # as written. Shared with the object-pin door, so both read a pin the same
144
152
  # way.
145
153
  def wire(type_name, value, coordinate = nil)
146
- return value if WIRE.any? { |klass| value.is_a?(klass) }
154
+ return value if self.class.wire?(value)
147
155
 
148
156
  serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
149
- return serialized if WIRE.any? { |klass| serialized.is_a?(klass) }
157
+ return serialized if self.class.wire?(serialized)
150
158
 
151
159
  article = GraphWeaver::Internal::Util.article(value.class.to_s)
152
160
  raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
@@ -18,6 +18,11 @@ module GraphWeaver
18
18
  # lexical scope, so a private constant would be unreachable from exactly
19
19
  # the files that need it. The name and the surface lock carry the rule.
20
20
  module Internal
21
+ # The wire value the member register_enum fallback: true adds to a
22
+ # generated enum serializes to. The GraphQL spec reserves a leading `__`,
23
+ # so no schema can declare a value that collides with it.
24
+ ENUM_FALLBACK_WIRE = "__other__"
25
+
21
26
  # Odds and ends several files share. Each is here because more than one
22
27
  # caller needs it, not because it belongs together with the others.
23
28
  module Util
@@ -50,6 +55,18 @@ module GraphWeaver
50
55
  # "a" or "an" for a word an error message is about to name.
51
56
  def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
52
57
 
58
+ # how many entries a message names before it says "and N more"
59
+ SAMPLE = 5
60
+ private_constant :SAMPLE
61
+
62
+ # A list a message names inline, held to a readable length — a wall
63
+ # of schema coordinates says less than the first few and a count.
64
+ def sample(list)
65
+ return list.join(", ") if list.size <= SAMPLE
66
+
67
+ "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
68
+ end
69
+
53
70
  # The module a .graphql file generates, and the basename of the file
54
71
  # it generates into: the camelized file name plus the operation's own
55
72
  # word. Every run of non-alphanumerics in the name is a word boundary,
@@ -299,6 +316,73 @@ module GraphWeaver
299
316
  end
300
317
  end
301
318
 
319
+ # One query, checked against one schema. Both doors onto it —
320
+ # GraphWeaver.check_queries (every file on disk) and Client#check_query
321
+ # (a string) — report the same hashes and brand subgraphs the same way,
322
+ # because the implementation lives here rather than once each.
323
+ module QueryCheck
324
+ class << self
325
+ # A query's schema-validation errors as JSON-ready hashes, with the
326
+ # source position graphql-ruby reports. Unparseable counts as an error
327
+ # too — it doesn't validate either, and inline_fragments (which parses
328
+ # first) has already branded it with its position.
329
+ def errors(schema, source, shared, table = nil)
330
+ # path omitted: check_queries keys its report by file, so branding the
331
+ # message with it too would just print the path twice
332
+ schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
333
+ detail = error.to_h
334
+ location = detail["locations"]&.first || {}
335
+ subgraphs = table ? attribute(table, detail["extensions"]) : []
336
+ entry = {
337
+ "message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
338
+ "line" => location["line"],
339
+ "column" => location["column"],
340
+ }
341
+ subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
342
+ end
343
+ rescue GraphWeaver::QueryValidationError => e
344
+ # an unparseable query: codegen folds the position (and the file) into
345
+ # the message, and this report keeps them separate — same splitter the
346
+ # rendered error uses, so the two can't drift apart
347
+ e.errors.map do |detail|
348
+ _path, _position, message = GraphWeaver::QueryValidationError.split(detail)
349
+ detail.transform_keys(&:to_s).merge("message" => message)
350
+ end
351
+ end
352
+
353
+ # The routing table behind a dump path, when the dump is a composed
354
+ # supergraph: it says who resolves what, so a validation error can name
355
+ # the subgraph whose code to look at. nil for anything else — a plain
356
+ # schema, a url client, a live class are all unaffected.
357
+ def routing_table_for(path)
358
+ path = path.to_path if path.respond_to?(:to_path)
359
+ return unless path&.end_with?(".graphql", ".gql")
360
+
361
+ sdl = File.read(path)
362
+ SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
363
+ end
364
+
365
+ private
366
+
367
+ # Which subgraphs a validation error is about, on a federated schema:
368
+ # "Field 'weight' doesn't exist on type 'Product'" is much less useful
369
+ # than the same line plus "(products)" — whose code to look at, whose
370
+ # team to talk to. graphql-ruby reports the coordinate structurally, so
371
+ # this is a lookup rather than message parsing. Both halves of the
372
+ # coordinate are required: an argument error reports typeName "Field"
373
+ # (the AST node kind, not a type), and looking that up would attribute
374
+ # confidently and wrongly.
375
+ def attribute(table, extensions)
376
+ return [] unless extensions
377
+
378
+ type_name, field_name = extensions.values_at("typeName", "fieldName")
379
+ return [] unless type_name && field_name
380
+
381
+ table.responsible(type_name, field_name)
382
+ end
383
+ end
384
+ end
385
+
302
386
  # What makes two GraphQL requests the same request — and how one reads
303
387
  # when an error has to quote it. A cassette matches on this, so the
304
388
  # rules belong somewhere both the cassette and the error that reports a
@@ -101,7 +101,11 @@ module GraphWeaver
101
101
  # about the key the value arrived under, so it reads a filtered key one
102
102
  # level in as safe; this scrubs at every depth, like #value. The key is
103
103
  # optional because a coercer refusing a value hasn't been told one.
104
- def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
104
+ def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(spell(value(key, raw)))
105
+
106
+ # How a value reads inside a sentence. inspect, except that
107
+ # BigDecimal#inspect is scientific ("0.25e1" for the 2.5 a caller wrote).
108
+ def spell(value) = defined?(BigDecimal) && value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
105
109
 
106
110
  # A short server-chosen string the library republishes inside its own
107
111
  # text — the APM's :code, the [CODE] in the one line info writes, a
@@ -215,36 +219,13 @@ module GraphWeaver
215
219
  end
216
220
 
217
221
  # What a Retry has already spent, read by the attempt it is about
218
- # to make. A dynamic extent rather than a global: the count is only
219
- # visible while the call it describes is on the stack, so a client
220
- # that never reaches instrument can't leave a stale one behind.
221
- def with_retries(count)
222
- return yield unless GraphWeaver.instrumenter
223
-
224
- previous = Thread.current[RETRIES]
225
- Thread.current[RETRIES] = count
226
- begin
227
- yield
228
- ensure
229
- Thread.current[RETRIES] = previous
230
- end
231
- end
222
+ # to make.
223
+ def with_retries(count, &block) = during(RETRIES, count, &block)
232
224
 
233
225
  # The graph a generated module is dispatching, read by the request it
234
- # is about to make. Same dynamic extent as with_retries, for the same
235
- # reason and instrument clears it for the duration of the request it
236
- # labels, so exactly one request wears the label.
237
- def with_graph(name)
238
- return yield unless GraphWeaver.instrumenter
239
-
240
- previous = Thread.current[GRAPH]
241
- Thread.current[GRAPH] = name
242
- begin
243
- yield
244
- ensure
245
- Thread.current[GRAPH] = previous
246
- end
247
- end
226
+ # is about to make and instrument clears it for the duration of the
227
+ # request it labels, so exactly one request wears the label.
228
+ def with_graph(name, &block) = during(GRAPH, name, &block)
248
229
 
249
230
  # The variables as one JSON line for a log: filtered, and unable to
250
231
  # raise. A value with no JSON form (NaN, binary) is the caller's bug
@@ -278,6 +259,22 @@ module GraphWeaver
278
259
 
279
260
  private
280
261
 
262
+ # One fiber-local, set for the length of one call. A dynamic extent
263
+ # rather than a global: the value is only visible while the call it
264
+ # describes is on the stack, so a client that never reaches instrument
265
+ # can't leave a stale one behind.
266
+ def during(key, value)
267
+ return yield unless GraphWeaver.instrumenter
268
+
269
+ previous = Thread.current[key]
270
+ Thread.current[key] = value
271
+ begin
272
+ yield
273
+ ensure
274
+ Thread.current[key] = previous
275
+ end
276
+ end
277
+
281
278
  # The GraphQL errors a response carries, whatever answered it — a
282
279
  # Hash from a transport, a graphql-ruby Result in-process, a fake.
283
280
  # Never raises: an instrumenter that decides which exception a
@@ -7,8 +7,6 @@ require_relative "internal"
7
7
  require_relative "internal/test_clients"
8
8
 
9
9
  module GraphWeaver
10
- # Called by generated code — not semver'd for direct use.
11
- #
12
10
  # Runtime for generated query modules: the client plumbing, which is the
13
11
  # one part of a generated module that carries no per-query type
14
12
  # information — every module's copy was identical. `extend
@@ -16,6 +14,13 @@ module GraphWeaver
16
14
  # stay generated, since their sigs are the query's types and those are the
17
15
  # point.
18
16
  #
17
+ # It is also the type every generated module satisfies, so code that takes
18
+ # any of them says `GraphWeaver::QueryModule` and reads `query_string` /
19
+ # `operation_name` with a sig behind each — rather than `const_get(:QUERY)`
20
+ # on a Module, which is what rubocop-sorbet forbids (ConstantsFromStrings,
21
+ # and ForbidTUnsafe for the T.unsafe that gets around it). Those readers
22
+ # and `client` are the supported surface; the rest is generated code's.
23
+ #
19
24
  # Resolution order, per the docs: per call → a test mode's stand-in
20
25
  # (Internal::TestClients) → the client the module's graph names →
21
26
  # `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
@@ -32,6 +37,18 @@ module GraphWeaver
32
37
  @client || default_client
33
38
  end
34
39
 
40
+ # The operation, verbatim — what goes on the wire as `query`.
41
+ sig { returns(String) }
42
+ def query_string
43
+ T.unsafe(self).const_get(:QUERY)
44
+ end
45
+
46
+ # What goes on the wire as `operationName`; nil for an anonymous operation.
47
+ sig { returns(T.nilable(String)) }
48
+ def operation_name
49
+ T.unsafe(self).const_get(:OPERATION_NAME)
50
+ end
51
+
35
52
  private
36
53
 
37
54
  # Bound by GraphWeaver.parse, which is the only caller: a parsed module
@@ -59,12 +76,10 @@ module GraphWeaver
59
76
  # comes through here.)
60
77
  GraphWeaver::Internal::Wire.check_variables!(variables)
61
78
 
62
- mod = T.unsafe(self)
63
79
  # the graph codegen baked in, never one inferred from the client — a
64
80
  # wrong label on a request is worse than no label
65
81
  GraphWeaver::Internal::Log.with_graph(graph_name) do
66
- client_for(client).execute(mod.const_get(:QUERY), variables:,
67
- operation_name: mod.const_get(:OPERATION_NAME))
82
+ client_for(client).execute(query_string, variables:, operation_name:)
68
83
  end
69
84
  end
70
85
 
@@ -35,7 +35,7 @@ class GraphWeaver::Railtie < Rails::Railtie
35
35
  KEYS = %i[watch].freeze
36
36
 
37
37
  def method_missing(name, *args)
38
- key = name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
38
+ key = setting(name)
39
39
  return super if KEYS.include?(key)
40
40
 
41
41
  raise ArgumentError, refusal(key)
@@ -53,11 +53,16 @@ class GraphWeaver::Railtie < Rails::Railtie
53
53
  alias_method :store, :[]=
54
54
 
55
55
  def respond_to_missing?(name, _private = false)
56
- KEYS.include?(name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym)
56
+ KEYS.include?(setting(name))
57
57
  end
58
58
 
59
59
  private
60
60
 
61
+ # the setting a reader, writer or predicate is about
62
+ def setting(name)
63
+ name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
64
+ end
65
+
61
66
  def refusal(key)
62
67
  near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
63
68
  fix =
@@ -592,7 +592,6 @@ module GraphWeaver
592
592
  "may itself be a proc, so it can vary per request."
593
593
  end
594
594
  private_class_method :wire_context!
595
-
596
595
  end
597
596
  end
598
597
  end