graph_weaver 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -12,10 +12,12 @@ class GraphWeaver::Codegen
12
12
  # scalars and overrides go through the same path.
13
13
  #
14
14
  # cast/serialize normalize to procs that, given a Ruby expression string,
15
- # return the code to inline. Left nil (the default) they are inferred
16
- # from the Ruby type when it is a real class, by probing for a known
17
- # deserializer and pairing its serializer (see CODECS) so the common
18
- # case needs no more than a class:
15
+ # return the code to inline. Left nil (the default) they are inferred from
16
+ # the Ruby type: a type the library already knows takes its codec from
17
+ # STDLIB, otherwise it is probed for a known deserializer whose serializer
18
+ # pairs with it (see CODECS), and failing that for a Kernel conversion
19
+ # function of its own name — so the common case needs no more than a class:
20
+ # type: BigDecimal (Kernel#BigDecimal) => BigDecimal(expr) / expr.to_s("F")
19
21
  # type: Money (defines .parse) => Money.parse(expr) / expr.to_s
20
22
  # type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
21
23
  # Probing the *deserialize* side is deliberate: every object has #to_s,
@@ -25,6 +27,7 @@ class GraphWeaver::Codegen
25
27
  # - a Symbol names a method, so there is no string to misspell:
26
28
  # cast: :load => "Blob.load(expr)" (class method on type)
27
29
  # serialize: :to_json => "expr.to_json" (instance method)
30
+ # - an Array is that method with arguments: serialize: [:to_s, "F"]
28
31
  # - a Proc handles anything a Symbol can't express:
29
32
  # cast: ->(e) { "Money.new(#{e})" }
30
33
  # - :itself opts out — force identity pass-through even when a codec
@@ -34,85 +37,117 @@ class GraphWeaver::Codegen
34
37
  class ScalarType
35
38
  # Inferred (deserialize, serialize) codecs, tried in order; the first
36
39
  # whose probe the Ruby type defines as a class method wins, and its
37
- # serialize is paired with it. Builders take (type_name, expr) => code.
38
- Codec = Struct.new(:probe, :cast, :serialize)
40
+ # serialize is paired with it. Builders take (type_name, expr) => code;
41
+ # `call` is the same serialization run rather than emitted, for the
42
+ # testing harness (see #serialize_value).
43
+ Codec = Struct.new(:probe, :cast, :serialize, :call)
39
44
  CODECS = [
40
45
  Codec.new(:parse, # Type.parse(wire) <-> value.to_s
41
46
  ->(type, expr) { "#{type}.parse(#{expr})" },
42
- ->(_type, expr) { "#{expr}.to_s" }),
47
+ ->(_type, expr) { "#{expr}.to_s" },
48
+ ->(_klass, value) { value.to_s }),
43
49
  Codec.new(:load, # Type.load(wire) <-> Type.dump(value)
44
50
  ->(type, expr) { "#{type}.load(#{expr})" },
45
- ->(type, expr) { "#{type}.dump(#{expr})" }),
51
+ ->(type, expr) { "#{type}.dump(#{expr})" },
52
+ ->(klass, value) { klass.dump(value) }),
46
53
  ].freeze
47
54
 
48
- # How a built-in converts a loose variable input, and the widened kwarg
49
- # it then accepts. Only the numerics can: a String/ID/Boolean input is
50
- # already its own Ruby type, so there is nothing to convert.
51
- Conversion = Struct.new(:via, :input_type)
52
- CONVERSIONS = {
53
- "Int" => Conversion.new(:to_i, "T.any(Integer, Float, String)"),
54
- "Float" => Conversion.new(:to_f, "T.any(Float, Integer, String)"),
55
+ # A scalar with no `cast:` to run input through: its Ruby type is the
56
+ # whole rule, so key on that a custom scalar registered as a plain
57
+ # String gets the same check. ID is the exception GraphQL itself names
58
+ # (see Coerce.id), matched by GraphQL name in #coercer.
59
+ COERCERS = {
60
+ "Integer" => "integer",
61
+ "Float" => "float",
62
+ "String" => "string",
63
+ "T::Boolean" => "boolean",
55
64
  }.freeze
56
65
 
66
+ # What the library already knows about a Ruby type, so registering one
67
+ # takes nothing but the class. Consulted only where the registration is
68
+ # silent; an explicit cast:/serialize:/requires: wins. Two things the
69
+ # probe above can't work out on its own:
70
+ # - the wire spelling. BigDecimal#to_s writes "0.125e2", which is not
71
+ # what any server means by 12.5, and Date.parse reads a great deal
72
+ # more than the ISO 8601 a Date scalar carries.
73
+ # - the file to require, so the generated source stands alone.
74
+ # Only types whose wire form is unambiguous belong here.
75
+ STDLIB = {
76
+ "BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
77
+ "Date" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
78
+ "Time" => { cast: :parse, serialize: :iso8601, requires: "time" },
79
+ "DateTime" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
80
+ }.freeze
81
+
82
+ # Everything JSON.parse can hand back. A registered type outside this set
83
+ # has to be BUILT from one of them, which is what a cast is for (and what
84
+ # Codegen#refuse_uncastable! insists on).
85
+ WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
86
+
87
+ private_constant :Codec, :CODECS, :COERCERS, :STDLIB
88
+
57
89
  attr_reader :graphql_name, :type, :requires
58
90
 
59
- def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
91
+ def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil)
60
92
  @graphql_name = graphql_name.to_s
61
93
  @klass = type.is_a?(Module) ? type : nil
62
94
  @type = type_name(type)
63
- # requires: load BEFORE codec probing — the probe method may come
64
- # from the required file (core Time has no .parse until the "time"
65
- # stdlib loads)
66
- @requires = normalize_requires(requires)
95
+ known = STDLIB[@type] || {}
96
+ # requires: load BEFORE probing the deserializer may arrive with the
97
+ # file (core Time has no .parse until the "time" stdlib loads, and
98
+ # Kernel#BigDecimal none until "bigdecimal" does). A path from STDLIB
99
+ # is the library's own, so it loads even for a type: given as a String,
100
+ # whose dependency we otherwise can't assume is installed.
101
+ @requires =
102
+ if requires.nil?
103
+ GraphWeaver::Codegen.normalize_requires!(known[:requires], load: true)
104
+ else
105
+ GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
106
+ end
67
107
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
68
- @cast = normalize_cast(cast, codec&.cast)
69
- @serialize = normalize_serialize(serialize, codec&.serialize)
70
- @coerce = coerce
71
- validate_coerce!
108
+ @cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
109
+ @serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
110
+ @serialize_value = runtime_serialize(serialize || known[:serialize], codec)
72
111
  end
73
112
 
74
113
  def cast(expr) = @cast&.call(expr)
75
114
  def cast? = !@cast.nil?
76
115
  def serialize(expr) = @serialize&.call(expr)
77
116
  def serialize? = !@serialize.nil?
78
- def coerce? = !!coercion
79
-
80
- # How this scalar coerces a variable input, or nil for not at all.
81
- # coerce: says WHETHER (explicit always wins); left unset the global
82
- # GraphWeaver.auto_coerce decides — resolved HERE, at generation time,
83
- # so registration order doesn't matter. The scalar itself says HOW: a
84
- # convertible built-in converts, anything with a full cast/serialize
85
- # pair parses. Nothing left to try means it can't coerce.
86
- def coercion
87
- return if @coerce == false
88
- return if @coerce.nil? && !GraphWeaver.auto_coerce
89
-
90
- CONVERSIONS[@graphql_name] || (:parse if cast? && serialize?)
91
- end
117
+ def coerce? = !coerce_input("v").nil?
92
118
 
93
- # The code that normalizes a variable input before it's serialized.
94
- # Parsing runs a raw value through the cast, guarded so an already-typed
95
- # value passes through; a conversion just calls the method (5, "5" ->
96
- # 5.0). serialize still runs afterward, but is identity for the
97
- # convertible built-ins, so the converted value goes on the wire
98
- # natively (a Float, not "5.0").
99
- def coerce_input(expr)
100
- case (how = coercion)
101
- when :parse then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
102
- when Conversion then "#{expr}.#{how.via}"
103
- end
119
+ # #serialize run rather than emitted: the wire value for a Ruby one. The
120
+ # testing harness reads app objects a Time, a Money — off an object pin
121
+ # and has to write what the server would. A `serialize:` proc builds code
122
+ # and can't be run, so its value passes through and the cast complains.
123
+ def serialize_value(value)
124
+ return value if value.nil? || @serialize_value.nil?
125
+
126
+ @serialize_value.call(value)
104
127
  end
105
128
 
106
- # the accepted Sorbet type for a coercible variable kwarg
107
- def coerce_type
108
- case (how = coercion)
109
- when :parse then "T.any(#{@type}, String)"
110
- when Conversion then how.input_type
129
+ # The code that normalizes a loose input — a Rails param — into this
130
+ # scalar's Ruby type before it is serialized, or nil for nothing to do.
131
+ # `cast:` is the how: it already knows how to build the Ruby object from
132
+ # a wire value, guarded so an already-typed value passes through. A
133
+ # scalar without one falls back to its Ruby type's check, which is what
134
+ # `.checked(:never)` on the generated sig gives up.
135
+ def coerce_input(expr)
136
+ if cast?
137
+ "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
138
+ elsif (fn = coercer)
139
+ "GraphWeaver::Coerce.#{fn}(#{expr})"
111
140
  end
112
141
  end
113
142
 
114
143
  private
115
144
 
145
+ def coercer
146
+ return "id" if @graphql_name == "ID" && @type == "String"
147
+
148
+ COERCERS[@type]
149
+ end
150
+
116
151
  def type_name(type)
117
152
  case type
118
153
  when Module
@@ -124,6 +159,17 @@ class GraphWeaver::Codegen
124
159
  end
125
160
  end
126
161
 
162
+ # Kernel's conversion functions are how a wire value becomes one of these
163
+ # — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
164
+ # read a decimal string all along. Only for a type the wire can't already
165
+ # be: Kernel#String and Kernel#Array wrap a value rather than convert it.
166
+ def kernel_cast
167
+ return unless Kernel.private_method_defined?(@type.to_sym)
168
+ return if WIRE_CLASSES.any? { |native| native.name == @type }
169
+
170
+ ->(type, expr) { "#{type}(#{expr})" }
171
+ end
172
+
127
173
  # nil infers via the matched codec; :itself opts out (identity); a
128
174
  # Symbol is a class method on the type — Money.parse(expr)
129
175
  def normalize_cast(cast, inferred)
@@ -136,37 +182,35 @@ class GraphWeaver::Codegen
136
182
  end
137
183
  end
138
184
 
139
- # nil infers via the matched codec; :itself opts out (identity); a
140
- # Symbol is an instance method on the value — expr.to_s
185
+ # nil infers via the matched codec; :itself opts out (identity); a Symbol
186
+ # is an instance method on the value — expr.to_s — and an Array is that
187
+ # method with arguments: [:to_s, "F"] => expr.to_s("F")
141
188
  def normalize_serialize(serialize, inferred)
142
189
  case serialize
143
190
  when :itself then nil
144
191
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
145
192
  when Proc then serialize
146
193
  when Symbol then ->(expr) { "#{expr}.#{serialize}" }
147
- else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
148
- end
149
- end
194
+ when Array
195
+ method, *args = serialize
196
+ unless method.is_a?(Symbol)
197
+ # a syntax error in the generated file otherwise
198
+ raise ArgumentError, "serialize: an Array is [method, *arguments], got #{serialize.inspect}"
199
+ end
150
200
 
151
- # With only a type-name string we can't assume the lib is installed at
152
- # codegen time, so the paths aren't loaded only shape-checked.
153
- def normalize_requires(requires)
154
- GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
201
+ ->(expr) { "#{expr}.#{method}(#{args.map(&:inspect).join(", ")})" }
202
+ else raise ArgumentError, "serialize: must be a Symbol, Array, Proc, :itself, or nil, got #{serialize.inspect}"
203
+ end
155
204
  end
156
205
 
157
- # coerce: true asks for something the scalar has to know how to do, so
158
- # refuse a pass-through one now rather than emit a silent no-op.
159
- def validate_coerce!
160
- case @coerce
161
- when false, nil then nil
162
- when true
163
- return if coercion
164
-
165
- raise ArgumentError,
166
- "coerce: true needs a cast and a serialize (#{@graphql_name} has neither, " \
167
- "so there is nothing to coerce)"
168
- else
169
- raise ArgumentError, "coerce: must be true or false, got #{@coerce.inspect}"
206
+ # The runnable half of normalize_serialize: a Symbol (or Symbol with
207
+ # arguments) names a method :itself included, which is identity either
208
+ # way — an inferred codec knows its own call, and a Proc emits code there
209
+ # is no way to run.
210
+ def runtime_serialize(serialize, codec)
211
+ case serialize
212
+ when Symbol, Array then ->(value) { value.public_send(*serialize) }
213
+ when nil then codec && ->(value) { codec.call.call(@klass, value) }
170
214
  end
171
215
  end
172
216
  end
@@ -200,18 +244,19 @@ class GraphWeaver::Codegen
200
244
  # the accepted cast:/serialize:/requires: forms. Later registrations
201
245
  # win, so an app can override a built-in (e.g. map Date onto its own
202
246
  # type).
203
- def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
247
+ def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil)
204
248
  scalar_registry[graphql_name.to_s] =
205
- ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
249
+ ScalarType.new(graphql_name, type, cast:, serialize:, requires:)
206
250
  end
207
251
 
208
- # The ScalarType for a scalar name; unknown scalars fall back to an
209
- # untyped pass-through (T.untyped, no cast) the prior behavior for
210
- # scalars outside the table.
211
- def scalar(graphql_name)
212
- scalar_registry.fetch(graphql_name.to_s) do
213
- ScalarType.new(graphql_name, "T.untyped")
214
- end
252
+ # The ScalarType in play for a scalar, most specific first: the
253
+ # `Type.field` registration when `coordinate` names one, then the
254
+ # scalar-name registration. Unknown scalars fall back to an untyped
255
+ # pass-through (T.untyped, no cast) — the prior behavior for scalars
256
+ # outside the table.
257
+ def scalar(graphql_name, coordinate = nil)
258
+ (coordinate && scalar_registry[coordinate.to_s]) ||
259
+ scalar_registry.fetch(graphql_name.to_s) { ScalarType.new(graphql_name, "T.untyped") }
215
260
  end
216
261
 
217
262
  def scalar_registry
@@ -226,33 +271,57 @@ class GraphWeaver::Codegen
226
271
  end
227
272
 
228
273
  # Drop every custom registration and restore the built-in scalars — the
229
- # clean slate to reach for between tests, or to undo overrides. (Want
230
- # the built-ins to coerce loose input? That's GraphWeaver.auto_coerce,
231
- # resolved at generation time — no re-registering.)
274
+ # clean slate to reach for between tests, or to undo overrides.
232
275
  def reset_scalars!
233
276
  clear_scalars!
234
277
  register_builtin_scalars!
235
278
  self
236
279
  end
237
280
 
238
- # Built-in scalars — pre-registered entries in the one registry. The
239
- # standard scalars stay pass-through: their Ruby classes (String,
240
- # Integer, Float) define neither .parse nor .load, so codec inference
241
- # matches nothing and leaves them identity which is exactly why we
242
- # can name them with the real class constants. Date deserializes via
243
- # ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
244
- # so it's explicit). Whether a variable of one accepts loose input is a
245
- # separate, generation-time question see coercion.
281
+ # Pre-registered scalars — ordinary entries in the one registry, so a
282
+ # later register_scalar overrides any of them.
283
+ #
284
+ # The five the spec names stay pass-through: their Ruby classes (String,
285
+ # Integer) define neither .parse nor .load, so inference matches nothing
286
+ # and leaves them identity which is exactly why we can name them with
287
+ # the real class constants. Float is the exception: JSON has one number
288
+ # type, so a whole Float arrives as `1` from every encoder that drops the
289
+ # trailing zero (graphql-js and Go both do), and Coerce.float widens that
290
+ # without accepting the garbage `.to_f` would silently turn into 0.0.
291
+ #
292
+ # The rest are names, not guesses: graphql-ruby ships all but DateTime as
293
+ # its own scalars, and this library runs a graphql-ruby schema in-process.
294
+ # DateTime is what GitHub, Shopify and most hand-written schemas call an
295
+ # ISO 8601 timestamp; a schema that means something else by it fails
296
+ # loudly (the cast raises, naming the field) and is one register_scalar
297
+ # away. Date and datetime are told apart by their Ruby type — a Date cast
298
+ # to Time would invent a midnight the server never sent.
246
299
  def register_builtin_scalars!
247
300
  register_scalar "ID", String
248
301
  register_scalar "String", String
249
302
  register_scalar "Int", Integer
250
- register_scalar "Float", Float
303
+ register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
251
304
  register_scalar "Boolean", "T::Boolean"
252
- register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
305
+ register_scalar "Date", Date
306
+ register_scalar "ISO8601Date", Date
307
+ register_scalar "ISO8601DateTime", Time
308
+ register_scalar "DateTime", Time
309
+ # graphql-ruby writes a BigInt as a string, since JSON numbers stop
310
+ # being exact at 2^53 — so read either spelling and write the one the
311
+ # server does.
312
+ register_scalar "BigInt", Integer,
313
+ cast: ->(expr) { "GraphWeaver::Coerce.integer(#{expr})" }, serialize: :to_s
314
+ # untyped on purpose: registering it says so, rather than leaving JSON
315
+ # in the "unregistered custom scalars" report every generation
316
+ register_scalar "JSON", "T.untyped"
253
317
  end
318
+ private :register_builtin_scalars!
254
319
  end
255
320
 
321
+ # codegen's own record of a registration; users get one back from
322
+ # `.scalar` but never name the class
323
+ private_constant :ScalarType
324
+
256
325
  register_builtin_scalars!
257
326
 
258
327
  # Pre-registered rather than user intent, so generation doesn't hold a schema
@@ -82,6 +82,7 @@ class GraphWeaver::Codegen
82
82
  # arbitrary text that could inject code
83
83
  ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
84
84
  ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
85
+ private_constant :ALIAS_NAME, :ALIAS_SEGMENT
85
86
 
86
87
  # { accessor => { segments:, optional: } } from a path string (accessor
87
88
  # named after the last segment), an array of such, or an { accessor => path }