graph_weaver 0.5.0 → 0.6.0

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  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 +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -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 +6 -13
  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 +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  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 +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  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 +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -34,29 +34,36 @@ class GraphWeaver::Codegen
34
34
  class ScalarType
35
35
  # Inferred (deserialize, serialize) codecs, tried in order; the first
36
36
  # 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)
37
+ # serialize is paired with it. Builders take (type_name, expr) => code;
38
+ # `call` is the same serialization run rather than emitted, for the
39
+ # testing harness (see #serialize_value).
40
+ Codec = Struct.new(:probe, :cast, :serialize, :call)
39
41
  CODECS = [
40
42
  Codec.new(:parse, # Type.parse(wire) <-> value.to_s
41
43
  ->(type, expr) { "#{type}.parse(#{expr})" },
42
- ->(_type, expr) { "#{expr}.to_s" }),
44
+ ->(_type, expr) { "#{expr}.to_s" },
45
+ ->(_klass, value) { value.to_s }),
43
46
  Codec.new(:load, # Type.load(wire) <-> Type.dump(value)
44
47
  ->(type, expr) { "#{type}.load(#{expr})" },
45
- ->(type, expr) { "#{type}.dump(#{expr})" }),
48
+ ->(type, expr) { "#{type}.dump(#{expr})" },
49
+ ->(klass, value) { klass.dump(value) }),
46
50
  ].freeze
47
51
 
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)"),
52
+ # A scalar with no `cast:` to run input through: its Ruby type is the
53
+ # whole rule, so key on that a custom scalar registered as a plain
54
+ # String gets the same check. ID is the exception GraphQL itself names
55
+ # (see Coerce.id), matched by GraphQL name in #coercer.
56
+ COERCERS = {
57
+ "Integer" => "integer",
58
+ "Float" => "float",
59
+ "String" => "string",
60
+ "T::Boolean" => "boolean",
55
61
  }.freeze
62
+ private_constant :Codec, :CODECS, :COERCERS
56
63
 
57
64
  attr_reader :graphql_name, :type, :requires
58
65
 
59
- def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
66
+ def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil)
60
67
  @graphql_name = graphql_name.to_s
61
68
  @klass = type.is_a?(Module) ? type : nil
62
69
  @type = type_name(type)
@@ -67,52 +74,47 @@ class GraphWeaver::Codegen
67
74
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
68
75
  @cast = normalize_cast(cast, codec&.cast)
69
76
  @serialize = normalize_serialize(serialize, codec&.serialize)
70
- @coerce = coerce
71
- validate_coerce!
77
+ @serialize_value = runtime_serialize(serialize, codec)
72
78
  end
73
79
 
74
80
  def cast(expr) = @cast&.call(expr)
75
81
  def cast? = !@cast.nil?
76
82
  def serialize(expr) = @serialize&.call(expr)
77
83
  def serialize? = !@serialize.nil?
78
- def coerce? = !!coercion
84
+ def coerce? = !coerce_input("v").nil?
79
85
 
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
86
+ # #serialize run rather than emitted: the wire value for a Ruby one. The
87
+ # testing harness reads app objects a Time, a Money — off an object pin
88
+ # and has to write what the server would. A `serialize:` proc builds code
89
+ # and can't be run, so its value passes through and the cast complains.
90
+ def serialize_value(value)
91
+ return value if value.nil? || @serialize_value.nil?
89
92
 
90
- CONVERSIONS[@graphql_name] || (:parse if cast? && serialize?)
93
+ @serialize_value.call(value)
91
94
  end
92
95
 
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").
96
+ # The code that normalizes a loose input a Rails param — into this
97
+ # scalar's Ruby type before it is serialized, or nil for nothing to do.
98
+ # `cast:` is the how: it already knows how to build the Ruby object from
99
+ # a wire value, guarded so an already-typed value passes through. A
100
+ # scalar without one falls back to its Ruby type's check, which is what
101
+ # `.checked(:never)` on the generated sig gives up.
99
102
  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
104
- end
105
-
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
103
+ if cast?
104
+ "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
105
+ elsif (fn = coercer)
106
+ "GraphWeaver::Coerce.#{fn}(#{expr})"
111
107
  end
112
108
  end
113
109
 
114
110
  private
115
111
 
112
+ def coercer
113
+ return "id" if @graphql_name == "ID" && @type == "String"
114
+
115
+ COERCERS[@type]
116
+ end
117
+
116
118
  def type_name(type)
117
119
  case type
118
120
  when Module
@@ -148,33 +150,23 @@ class GraphWeaver::Codegen
148
150
  end
149
151
  end
150
152
 
153
+ # The runnable half of normalize_serialize: a Symbol names a method
154
+ # (:itself included, which is identity either way), an inferred codec
155
+ # knows its own call, and a Proc emits code there is no way to run.
156
+ def runtime_serialize(serialize, codec)
157
+ case serialize
158
+ when Symbol then ->(value) { value.public_send(serialize) }
159
+ when nil then codec && ->(value) { codec.call.call(@klass, value) }
160
+ end
161
+ end
162
+
151
163
  # With only a type-name string we can't assume the lib is installed at
152
164
  # codegen time, so the paths aren't loaded — only shape-checked.
153
165
  def normalize_requires(requires)
154
166
  GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
155
167
  end
156
-
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}"
170
- end
171
- end
172
168
  end
173
169
 
174
- # Pre-registered rather than user intent (see register_builtin_scalars!), so
175
- # generation doesn't hold a schema to them.
176
- BUILTIN_SCALARS = %w[ID String Int Float Boolean Date].freeze
177
-
178
170
  class << self
179
171
  # requires: is a require path or list of them; each must be a non-empty
180
172
  # String (it is emitted verbatim as `require "..."` atop the generated
@@ -204,18 +196,19 @@ class GraphWeaver::Codegen
204
196
  # the accepted cast:/serialize:/requires: forms. Later registrations
205
197
  # win, so an app can override a built-in (e.g. map Date onto its own
206
198
  # type).
207
- def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
199
+ def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil)
208
200
  scalar_registry[graphql_name.to_s] =
209
- ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
201
+ ScalarType.new(graphql_name, type, cast:, serialize:, requires:)
210
202
  end
211
203
 
212
- # The ScalarType for a scalar name; unknown scalars fall back to an
213
- # untyped pass-through (T.untyped, no cast) the prior behavior for
214
- # scalars outside the table.
215
- def scalar(graphql_name)
216
- scalar_registry.fetch(graphql_name.to_s) do
217
- ScalarType.new(graphql_name, "T.untyped")
218
- end
204
+ # The ScalarType in play for a scalar, most specific first: the
205
+ # `Type.field` registration when `coordinate` names one, then the
206
+ # scalar-name registration. Unknown scalars fall back to an untyped
207
+ # pass-through (T.untyped, no cast) — the prior behavior for scalars
208
+ # outside the table.
209
+ def scalar(graphql_name, coordinate = nil)
210
+ (coordinate && scalar_registry[coordinate.to_s]) ||
211
+ scalar_registry.fetch(graphql_name.to_s) { ScalarType.new(graphql_name, "T.untyped") }
219
212
  end
220
213
 
221
214
  def scalar_registry
@@ -230,32 +223,43 @@ class GraphWeaver::Codegen
230
223
  end
231
224
 
232
225
  # Drop every custom registration and restore the built-in scalars — the
233
- # clean slate to reach for between tests, or to undo overrides. (Want
234
- # the built-ins to coerce loose input? That's GraphWeaver.auto_coerce,
235
- # resolved at generation time — no re-registering.)
226
+ # clean slate to reach for between tests, or to undo overrides.
236
227
  def reset_scalars!
237
228
  clear_scalars!
238
229
  register_builtin_scalars!
239
230
  self
240
231
  end
241
232
 
242
- # Built-in scalars — pre-registered entries in the one registry. The
243
- # standard scalars stay pass-through: their Ruby classes (String,
244
- # Integer, Float) define neither .parse nor .load, so codec inference
245
- # matches nothing and leaves them identity which is exactly why we
246
- # can name them with the real class constants. Date deserializes via
247
- # ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
248
- # so it's explicit). Whether a variable of one accepts loose input is a
249
- # separate, generation-time question see coercion.
233
+ # Built-in scalars — pre-registered entries in the one registry. Most stay
234
+ # pass-through: their Ruby classes (String, Integer) define neither .parse
235
+ # nor .load, so codec inference matches nothing and leaves them identity —
236
+ # which is exactly why we can name them with the real class constants.
237
+ # Float is the exception: JSON has one number type, so a whole Float
238
+ # arrives as `1` from every encoder that drops the trailing zero
239
+ # (graphql-js and Go both do), and Coerce.float widens that without
240
+ # accepting the garbage `.to_f` would silently turn into 0.0. Date
241
+ # deserializes via ISO-8601 (it *does* define .parse, but we want iso8601
242
+ # specifically, so it's explicit). The rest carry no cast and coerce
243
+ # input by their Ruby type — see coerce_input.
250
244
  def register_builtin_scalars!
251
245
  register_scalar "ID", String
252
246
  register_scalar "String", String
253
247
  register_scalar "Int", Integer
254
- register_scalar "Float", Float
248
+ register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
255
249
  register_scalar "Boolean", "T::Boolean"
256
250
  register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
257
251
  end
252
+ private :register_builtin_scalars!
258
253
  end
259
254
 
255
+ # codegen's own record of a registration; users get one back from
256
+ # `.scalar` but never name the class
257
+ private_constant :ScalarType
258
+
260
259
  register_builtin_scalars!
260
+
261
+ # Pre-registered rather than user intent, so generation doesn't hold a schema
262
+ # to them (validate_registration! skips these). Read off the registry the line
263
+ # above just filled: a seventh built-in shouldn't have to be named twice.
264
+ BUILTIN_SCALARS = scalar_registry.keys.freeze
261
265
  end
@@ -54,9 +54,7 @@ class GraphWeaver::Codegen
54
54
  # won't resolve yet — see EnumType for why that's a Rails initializer
55
55
  if mixin.is_a?(String)
56
56
  raise ArgumentError, "type helpers are the modules themselves, not their names — " \
57
- "extend_type(#{graphql_name.to_s.inspect}, #{mixin}). An autoloaded constant isn't " \
58
- "resolvable while config/initializers run; register from a " \
59
- "Rails.application.config.to_prepare block, which generation also runs first."
57
+ "extend_type(#{graphql_name.to_s.inspect}, #{mixin}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
60
58
  end
61
59
  unless mixin.is_a?(Module) && mixin.name
62
60
  raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
@@ -84,6 +82,7 @@ class GraphWeaver::Codegen
84
82
  # arbitrary text that could inject code
85
83
  ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
86
84
  ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
85
+ private_constant :ALIAS_NAME, :ALIAS_SEGMENT
87
86
 
88
87
  # { accessor => { segments:, optional: } } from a path string (accessor
89
88
  # named after the last segment), an array of such, or an { accessor => path }