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,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  class GraphWeaver::Codegen
5
- # How one GraphQL enum maps onto an app-owned T::Enum, so generated
6
- # code speaks YOUR enum instead of generating one per module:
5
+ # What a register_enum said about one GraphQL enum: the app-owned T::Enum
6
+ # its values map onto, and which wire spellings are the same value.
7
7
  #
8
8
  # class PetKind < T::Enum
9
9
  # enums { Cat = new("cat"); Dog = new("dog") }
@@ -17,11 +17,27 @@ class GraphWeaver::Codegen
17
17
  # value the schema declares must resolve — generation fails naming the
18
18
  # gaps — unless fallback: names a member to absorb unknown values
19
19
  # (forward-compat for servers that add members; inputs stay strict).
20
+ #
21
+ # alias: { "legacy_mode" => "LEGACY_MODE" } says two wire values are one
22
+ # value — both spellings cast, the target is what serializes. It is the
23
+ # whole registration when there is no T::Enum to map onto, and then the
24
+ # generated enum gets one constant for the target and none for the alias.
25
+ #
26
+ # fallback: true is the same type-less form asking for leniency instead:
27
+ # the generated enum gains an Other member and absorbs undeclared wire
28
+ # values into it (see Codegen#enum_values).
20
29
  class EnumType
21
30
  attr_reader :graphql_name, :type, :fallback, :requires
22
31
 
23
- def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
32
+ def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil, aliases: nil)
24
33
  @graphql_name = graphql_name.to_s
34
+ @aliases = normalize_aliases!(aliases)
35
+ @type = type
36
+ @map = map || {}
37
+ @fallback = fallback
38
+
39
+ return alias_only!(map, fallback, requires) if type.nil?
40
+
25
41
  # A name, not the class, is what you write when the constant won't
26
42
  # resolve yet — which in Rails means a config/initializers file, since
27
43
  # autoloading is set up after those run. Say where it does resolve.
@@ -36,9 +52,6 @@ class GraphWeaver::Codegen
36
52
  raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
37
53
  end
38
54
 
39
- @type = type
40
- @map = map || {}
41
- @fallback = fallback
42
55
  @requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
43
56
 
44
57
  if fallback && !type.values.include?(fallback)
@@ -46,28 +59,140 @@ class GraphWeaver::Codegen
46
59
  end
47
60
  end
48
61
 
49
- # wire value => member for every value the schema declares; raises
50
- # naming the unmappable ones (unless fallback: absorbs them)
51
- def mapping_for(wire_values)
52
- mapping = {}
62
+ # register_enum("Species", fallback: true): the generated enum gains an
63
+ # Other member and casts every undeclared wire value to it.
64
+ def generated_fallback? = type.nil? && fallback == true
65
+
66
+ # The wire tables for a mapped enum: [wire value => member, member => the
67
+ # wire value that goes out]. Every spelling casts; an alias's target is the
68
+ # one that serializes.
69
+ def tables_for(wire_values)
70
+ aliases = aliases_for(wire_values)
71
+ from_wire = {}
53
72
  missing = []
54
73
 
55
74
  wire_values.each do |wire|
56
- member = @map[wire] || infer(wire)
57
- member ? mapping[wire] = member : missing << wire
75
+ canonical = aliases.fetch(wire, wire)
76
+ member = @map[canonical] || infer(canonical)
77
+ member ? from_wire[wire] = member : missing << canonical
58
78
  end
59
79
 
60
80
  if missing.any? && !fallback
61
81
  raise GraphWeaver::Error,
62
- "#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
82
+ "#{type} has no member for #{graphql_name} value(s) #{missing.uniq.join(", ")} — " \
63
83
  "add them, pin with map:, or absorb with fallback:"
64
84
  end
65
85
 
66
- mapping
86
+ [from_wire, to_wire(from_wire, aliases)]
87
+ end
88
+
89
+ # alias spelling => the value it is read as, checked against what the
90
+ # schema declares — an alias for or onto a value that isn't there is a
91
+ # registration this schema disproves.
92
+ def aliases_for(wire_values)
93
+ declared = wire_values.sort.join(", ")
94
+
95
+ @aliases.each do |from, to|
96
+ unless wire_values.include?(from)
97
+ raise GraphWeaver::Error,
98
+ "enum #{graphql_name} has no value #{from.inspect} to alias — its values are #{declared}; " \
99
+ "fix the spelling or drop the alias"
100
+ end
101
+ unless wire_values.include?(to)
102
+ raise GraphWeaver::Error,
103
+ "enum #{graphql_name}: alias #{from.inspect} => #{to.inspect} names no value of #{graphql_name} — " \
104
+ "its values are #{declared}; the target is the spelling that goes on the wire"
105
+ end
106
+ end
107
+
108
+ @aliases
109
+ end
110
+
111
+ # The register_enum a set of indistinguishable wire values needs, ready to
112
+ # paste. SCREAMING_CASE is the GraphQL convention, so the odd spelling is
113
+ # guessed as the alias — every caller's sentence says to check the direction.
114
+ def self.alias_suggestion(graphql_name, groups, type = nil)
115
+ pairs = groups.flat_map { |group|
116
+ target = group.find { |value| value == value.upcase } || group.first
117
+ (group - [target]).map { |value| "#{value.inspect} => #{target.inspect}" }
118
+ }
119
+
120
+ "GraphWeaver.register_enum(#{graphql_name.inspect}#{type ? ", #{type}" : ""}, " \
121
+ "alias: { #{pairs.join(", ")} })"
67
122
  end
68
123
 
69
124
  private
70
125
 
126
+ # Without a T::Enum there is nothing for map:/requires: to describe, so
127
+ # alias: and fallback: true are the whole registration.
128
+ def alias_only!(map, fallback, requires)
129
+ if fallback && fallback != true
130
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}, fallback: #{fallback.inspect}): the " \
131
+ "generated enum generates its fallback member too, so say fallback: true. To fall back onto a " \
132
+ "member of your own, pass the T::Enum: " \
133
+ "register_enum(#{graphql_name.inspect}, YourEnum, fallback: YourEnum::Unknown)"
134
+ end
135
+
136
+ if @aliases.empty? && !fallback
137
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}) says nothing about #{graphql_name} — " \
138
+ "pass the T::Enum to map it onto, alias: { \"old\" => \"NEW\" } to read two wire values as one, " \
139
+ "or fallback: true to absorb values the server adds"
140
+ end
141
+
142
+ extra = { map:, requires: }.compact.keys.first
143
+ if extra
144
+ raise ArgumentError,
145
+ "register_enum(#{graphql_name.inspect}) takes no #{extra}: — that describes a T::Enum of your own, " \
146
+ "so pass one: register_enum(#{graphql_name.inspect}, YourEnum, #{extra}: {...})"
147
+ end
148
+
149
+ @requires = []
150
+ end
151
+
152
+ # Sorted so generated source is stable across registration order.
153
+ def normalize_aliases!(aliases)
154
+ return {} if aliases.nil?
155
+
156
+ unless aliases.is_a?(Hash)
157
+ raise ArgumentError, "alias: is a hash of wire value => wire value, got #{aliases.inspect}"
158
+ end
159
+
160
+ pairs = aliases.to_h { |from, to| [from.to_s, to.to_s] }
161
+
162
+ self_alias = pairs.find { |from, to| from == to }
163
+ if self_alias
164
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}): alias #{self_alias.first.inspect} => " \
165
+ "#{self_alias.last.inspect} reads a value as itself — drop it"
166
+ end
167
+
168
+ chained = pairs.keys.find { |from| pairs.value?(from) }
169
+ if chained
170
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}): #{chained.inspect} is both an alias and " \
171
+ "the value an alias points at — an alias can't chain; point every spelling at the one that goes on the wire"
172
+ end
173
+
174
+ pairs.sort.to_h
175
+ end
176
+
177
+ # member => the one wire value it serializes to. Two spellings on one
178
+ # member with no alias saying which goes out is ambiguous — invert used to
179
+ # pick whichever came last, which for a deprecation pair was the dead one.
180
+ def to_wire(from_wire, aliases)
181
+ canonical = from_wire.except(*aliases.keys)
182
+ ambiguous = canonical.group_by { |_, member| member }.select { |_, pairs| pairs.size > 1 }
183
+ if ambiguous.any?
184
+ member, = ambiguous.first
185
+ groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
186
+ more = ambiguous.size - 1
187
+ raise GraphWeaver::Error,
188
+ "enum #{graphql_name}: #{groups.first.join(" and ")} both map onto the #{type} member " \
189
+ "#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
190
+ "goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
191
+ end
192
+
193
+ canonical.invert
194
+ end
195
+
71
196
  # "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
72
197
  def infer(wire)
73
198
  @type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
@@ -80,19 +205,24 @@ class GraphWeaver::Codegen
80
205
 
81
206
  # The enum half of one graph's registrations — see Codegen::Registry.
82
207
  class Registry
83
- # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
84
- # implementation GraphWeaver.register_enum is a delegate, so the same
85
- # call reaches it whichever door you came in by.
208
+ # Map a GraphQL enum onto an app-owned T::Enum, fold two of its wire
209
+ # spellings into one value, or absorb the ones the server hasn't told you
210
+ # about yet (see EnumType). The one implementation
211
+ # GraphWeaver.register_enum is a delegate, so the same call reaches it
212
+ # whichever door you came in by.
86
213
  #
87
214
  # A value map is a natural third *positional* guess, and Ruby's arity
88
215
  # complaint ("given 3, expected 2") never mentions the keyword.
89
- def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
216
+ def register_enum(graphql_name, type = nil, positional_map = nil, map: nil, fallback: nil, requires: nil,
217
+ alias: nil)
90
218
  if positional_map
91
219
  raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
92
220
  "register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
93
221
  end
94
222
 
95
- enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
223
+ # `alias` is a Ruby keyword, so the parameter is only readable through binding
224
+ aliases = binding.local_variable_get(:alias)
225
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:, aliases:)
96
226
  end
97
227
 
98
228
  def enum_registry
@@ -113,11 +113,11 @@ class GraphWeaver::Codegen
113
113
 
114
114
  def cast(expr, depth)
115
115
  var = "v#{depth}"
116
- element = if @of.non_null? || @of.identity?
117
- @of.identity? ? var : @of.cast(var, depth + 1)
118
- else
119
- "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
120
- end
116
+ element =
117
+ if @of.identity? then var
118
+ elsif @of.non_null? then @of.cast(var, depth + 1)
119
+ else "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
120
+ end
121
121
 
122
122
  "#{expr}.map { |#{var}| #{element} }"
123
123
  end
@@ -127,11 +127,11 @@ class GraphWeaver::Codegen
127
127
 
128
128
  def serialize(expr, depth)
129
129
  var = "v#{depth}"
130
- element = if @of.non_null? || @of.serialize_identity?
131
- @of.serialize_identity? ? var : @of.serialize(var, depth + 1)
132
- else
133
- "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
134
- end
130
+ element =
131
+ if @of.serialize_identity? then var
132
+ elsif @of.non_null? then @of.serialize(var, depth + 1)
133
+ else "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
134
+ end
135
135
 
136
136
  "#{expr}.map { |#{var}| #{element} }"
137
137
  end
@@ -153,11 +153,11 @@ class GraphWeaver::Codegen
153
153
 
154
154
  def hash_coerce(expr, depth)
155
155
  var = "v#{depth}"
156
- inner = if @of.non_null? || @of.hash_coerce_identity?
157
- @of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
158
- else
159
- "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
160
- end
156
+ inner =
157
+ if @of.hash_coerce_identity? then var
158
+ elsif @of.non_null? then @of.hash_coerce(var, depth + 1)
159
+ else "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
160
+ end
161
161
  return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
162
162
 
163
163
  # the index is a path segment — `where._and.0._not.species` needs the 0
@@ -178,14 +178,16 @@ class GraphWeaver::Codegen
178
178
 
179
179
  attr_reader :class_name, :fields
180
180
  # the GraphQL type this struct was generated from, any registered helper
181
- # modules to include, and any resolved alias delegators (see extend_type)
182
- attr_accessor :graphql_type, :mixins, :aliases
181
+ # modules to include, any resolved alias delegators, and the member names a
182
+ # mixin declares abstract — Sorbet demands the override on those (extend_type)
183
+ attr_accessor :graphql_type, :mixins, :aliases, :overrides
183
184
 
184
185
  def initialize(class_name)
185
186
  @class_name = class_name
186
187
  @fields = []
187
188
  @mixins = []
188
189
  @aliases = []
190
+ @overrides = []
189
191
  end
190
192
 
191
193
  def bare_type = class_name
@@ -203,19 +205,37 @@ class GraphWeaver::Codegen
203
205
  end
204
206
 
205
207
  class EnumNode < Node
206
- attr_reader :class_name, :values
208
+ attr_reader :class_name, :values, :aliases
207
209
 
208
- def initialize(class_name, values)
210
+ # fallback: the name of the member unknown wire values cast to (register_enum
211
+ # fallback: true), or nil for a strict enum
212
+ def initialize(class_name, values, aliases = {}, fallback: nil)
209
213
  @class_name = class_name
210
214
  @values = values
215
+ @aliases = aliases
216
+ @fallback = fallback
211
217
  end
212
218
 
213
219
  def bare_type = class_name
214
220
 
221
+ # wire spellings this enum reads as another of its values (register_enum
222
+ # alias:) — a module-level table, emitted beside the class
223
+ def aliased? = !@aliases.empty?
224
+ def alias_const = "#{GraphWeaver::Inflect.underscore(class_name).upcase}_ALIASES"
225
+
226
+ # register_enum fallback: true — the extra member every value the schema
227
+ # doesn't declare casts to, and the one member a variable can't send
228
+ attr_reader :fallback
229
+ def fallback? = !@fallback.nil?
230
+ def fallback_const = "#{class_name}::#{@fallback}"
231
+
215
232
  def cast(expr, _depth)
216
- "GraphWeaver::Hints.enum(#{class_name}, #{expr})"
233
+ "GraphWeaver::Hints.enum(#{class_name}, #{expr}#{runtime_args})"
217
234
  end
218
235
 
236
+ # Other serializes to ENUM_FALLBACK_WIRE, which casts back to Other — so a
237
+ # result carrying it still round-trips through #as_json, even though no
238
+ # server would accept that spelling.
219
239
  def serialize(expr, _depth)
220
240
  "#{expr}.serialize"
221
241
  end
@@ -227,7 +247,11 @@ class GraphWeaver::Codegen
227
247
  def coerce? = true
228
248
 
229
249
  def coerce(expr)
230
- "GraphWeaver::InputStruct.enum(#{class_name}, #{expr})"
250
+ "GraphWeaver::InputStruct.enum(#{class_name}, #{expr}#{runtime_args})"
251
+ end
252
+
253
+ def runtime_args
254
+ "#{", #{alias_const}" if aliased?}#{", fallback: #{fallback_const}" if fallback?}"
231
255
  end
232
256
 
233
257
  def input_type = "T.any(#{class_name}, String)"
@@ -240,13 +264,13 @@ class GraphWeaver::Codegen
240
264
  # <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
241
265
  # casting absorb unknown wire values (inputs stay strict).
242
266
  class MappedEnum < Node
243
- attr_reader :graphql_name, :mapping
267
+ attr_reader :graphql_name, :mapping, :to_wire
244
268
 
245
269
  def initialize(enum_type, wire_values)
246
270
  @graphql_name = enum_type.graphql_name
247
271
  @type_name = enum_type.type.name
248
272
  @fallback = enum_type.fallback
249
- @mapping = enum_type.mapping_for(wire_values)
273
+ @mapping, @to_wire = enum_type.tables_for(wire_values)
250
274
  end
251
275
 
252
276
  def const_prefix = GraphWeaver::Inflect.underscore(@graphql_name).upcase
@@ -350,20 +374,22 @@ class GraphWeaver::Codegen
350
374
  def nested = self
351
375
  end
352
376
 
353
- # A reference to a union hoisted into the shared types module (a named
354
- # shared fragment spread as a whole union field): the query references
355
- # <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
356
- # alias the query module gives GraphQLTypes::<Name>. The type family lives
357
- # once in the shared module, so the same union across queries is one Ruby
358
- # type nested is nil, nothing is emitted here.
359
- class UnionRefNode < Node
360
- attr_reader :class_name
361
-
362
- def initialize(class_name)
377
+ # A reference to a type hoisted into the shared types module (a named shared
378
+ # fragment spread as a whole field): the query casts through <Name>.from_h,
379
+ # where <Name> is the alias the query module gives GraphQLTypes::<Name>. The
380
+ # type lives once in the shared module, so the same fragment across queries
381
+ # is one Ruby type nested is nil, nothing is emitted here.
382
+ class HoistedRefNode < Node
383
+ # graphql_type is carried for refusals alone — an alias path that tries to
384
+ # read into the hoisted struct says which type to register itself on
385
+ attr_reader :class_name, :graphql_type
386
+
387
+ def initialize(class_name, graphql_type = nil)
363
388
  @class_name = class_name
389
+ @graphql_type = graphql_type
364
390
  end
365
391
 
366
- def bare_type = "#{class_name}::Type"
392
+ def bare_type = class_name
367
393
 
368
394
  def cast(expr, _depth)
369
395
  "#{class_name}.from_h(#{expr})"
@@ -374,12 +400,21 @@ class GraphWeaver::Codegen
374
400
  end
375
401
  end
376
402
 
403
+ # The same, on an abstract type: the hoisted name is a dispatch module, so
404
+ # the Ruby type is its member union rather than the module itself.
405
+ class UnionRefNode < HoistedRefNode
406
+ def bare_type = "#{class_name}::Type"
407
+ end
408
+
377
409
  # An input-object variable: emitted as a module-level T::Struct whose
378
410
  # serialize produces the wire hash. Inputs never cast FROM the wire.
379
411
  # Joins the coerce protocol so execute kwargs accept plain hashes,
380
412
  # normalized (and type-checked) through the generated .coerce.
381
413
  class InputNode < Node
382
- Field = Struct.new(:prop, :wire, :node, :required)
414
+ # type: the schema's spelling of the field's type ("[Float!]!"), which a
415
+ # refusal reports — the Sorbet prop type is the library's vocabulary, not
416
+ # the caller's.
417
+ Field = Struct.new(:prop, :wire, :node, :required, :type)
383
418
 
384
419
  # graphql_name as well as class_name: a schema coordinate is spelled the
385
420
  # schema's way (pokemon_bool_exp.name), which camelize has already lost.
@@ -443,6 +478,6 @@ class GraphWeaver::Codegen
443
478
  # The IR is codegen's own vocabulary — every node type is reachable only
444
479
  # from inside the walk.
445
480
  private_constant :Node, :Scalar, :NonNull, :List, :ObjectNode, :EnumNode,
446
- :MappedEnum, :NarrowedNode, :UnionNode, :UnionRefNode, :InputNode,
447
- :RepresentationNode
481
+ :MappedEnum, :NarrowedNode, :UnionNode, :HoistedRefNode, :UnionRefNode,
482
+ :InputNode, :RepresentationNode
448
483
  end
@@ -56,6 +56,8 @@ class GraphWeaver::Codegen
56
56
  # is the whole rule, so key on that — a custom scalar registered as a
57
57
  # plain String gets the same check. ID is the exception GraphQL itself
58
58
  # names (see Coerce.id), matched by GraphQL name in #coercer.
59
+ # Read back off the wire too, where the type is one JSON already holds
60
+ # (see #wire_cast).
59
61
  COERCERS = {
60
62
  "Integer" => "integer",
61
63
  "Float" => "float",
@@ -83,15 +85,15 @@ class GraphWeaver::Codegen
83
85
 
84
86
  STDLIB = {
85
87
  "BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
86
- # JSON has one number type, so a whole Float arrives as `1` from every
87
- # encoder that drops the trailing zero (graphql-js and Go both do)
88
- "Float" => { cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" } },
89
88
  # strftime, not #iso8601: DateTime < Date passes the is_a? guard, and its
90
89
  # #iso8601 writes a timestamp where the schema said a date goes
91
90
  "Date" => { cast: :iso8601, serialize: [:strftime, "%F"], requires: "date" },
92
- # #iso8601 takes no precision, so it writes whole seconds and a
93
- # sub-second timestamp goes back out poorer than it came in
94
- "Time" => { cast: :parse, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "time" },
91
+ # Time.iso8601, not Time.parse: the latter also reads "Jan 15 2024
92
+ # 10:20", which no spec-compliant server writes, at the cost.
93
+ # Writing back goes through Coerce.timestamp rather than #iso8601,
94
+ # which takes no precision — a sub-second timestamp would go out
95
+ # poorer than it came in.
96
+ "Time" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "time" },
95
97
  "DateTime" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "date" },
96
98
  }.freeze
97
99
 
@@ -122,11 +124,12 @@ class GraphWeaver::Codegen
122
124
  end
123
125
  @cast_given = cast unless cast == :itself
124
126
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
125
- @cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
127
+ @cast = normalize_cast(cast || known[:cast], wire_cast || codec&.cast || kernel_cast)
126
128
  @serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
127
129
  @serialize_value = (known[:call] if serialize.nil?) ||
128
130
  runtime_serialize(serialize || known[:serialize], codec)
129
131
  warn_half_a_value_object
132
+ warn_half_a_codec(serialize)
130
133
  end
131
134
 
132
135
  def cast(expr) = @cast&.call(expr)
@@ -145,6 +148,10 @@ class GraphWeaver::Codegen
145
148
  @serialize_value.call(value)
146
149
  end
147
150
 
151
+ # Whether #serialize_value actually runs this registration's serializer,
152
+ # rather than passing the value through for want of one to run.
153
+ def serialize_value? = !@serialize_value.nil?
154
+
148
155
  # The code that normalizes a loose input — a Rails param — into this
149
156
  # scalar's Ruby type before it is serialized, or nil for nothing to do.
150
157
  # The Ruby type's own rule in Coerce is the check, which is what
@@ -207,6 +214,32 @@ class GraphWeaver::Codegen
207
214
  end
208
215
  end
209
216
 
217
+ # The other half of the same rule: a registration says both directions or
218
+ # is told what it can't do. A cast with nothing to write back reads the
219
+ # wire and can't put a variable of that scalar onto it, and a result's
220
+ # #as_json can't reproduce what the server sent — both silent, since
221
+ # `#to_json` answers for any object.
222
+ def warn_half_a_codec(serialize)
223
+ # nothing said and nothing inferred: `serialize: :itself` said it, and a
224
+ # Ruby type JSON already holds has no other half to name
225
+ return if !cast? || serialize? || !serialize.nil? || json_shaped?
226
+
227
+ GraphWeaver::Internal::Log.log(:warn) do
228
+ "register_scalar(#{@graphql_name.inspect}, #{@klass ? @type : @type.inspect}): cast: reads " \
229
+ "#{GraphWeaver::Internal::Util.article(@type)} #{@type} off the wire and nothing writes one " \
230
+ "back — a #{@graphql_name} variable goes out as whatever #to_json makes of it, and a result's " \
231
+ "#as_json can't reproduce what the server sent. Name the other half (serialize: :to_s names an " \
232
+ "instance method, serialize: [:to_s, \"F\"] passes it arguments, " \
233
+ "serialize: ->(v) { \"\#{v}.to_s\" } emits any expression), or serialize: :itself if the value " \
234
+ "really does go out as it is"
235
+ end
236
+ end
237
+
238
+ # A Ruby type JSON already holds writes itself, subclasses included
239
+ def json_shaped?
240
+ wire_class?(@type) || (!@klass.nil? && WIRE_CLASSES.any? { |native| @klass <= native })
241
+ end
242
+
210
243
  def defines?(method)
211
244
  ![BasicObject, Kernel, Object].include?(@klass.instance_method(method).owner)
212
245
  end
@@ -231,13 +264,31 @@ class GraphWeaver::Codegen
231
264
  end
232
265
  end
233
266
 
267
+ # `register_scalar("Count", Integer)` says the scalar IS an Integer, so the
268
+ # library's own rule for that class runs in both directions — the entry
269
+ # #coercer brands a variable with, reading the wire as well. It is the
270
+ # lenient reading: a custom scalar is the server's own, and may write the
271
+ # number as a string where the spec's `Int` may not (which is why the
272
+ # pre-registered Int/String/Boolean/ID opt out with `cast: :itself`).
273
+ # Only the wire class itself — Coerce hands back the plain type, so a
274
+ # subclass would land in a prop it doesn't satisfy — and only where Coerce
275
+ # has a rule, leaving Hash and Array passing through.
276
+ def wire_cast
277
+ return unless wire_class?(@type) && (fn = COERCERS[@type])
278
+
279
+ ->(_type, expr) { "GraphWeaver::Coerce.#{fn}(#{expr}, #{@graphql_name.inspect})" }
280
+ end
281
+
282
+ # T::Boolean is the pair TrueClass/FalseClass, spelled as Sorbet types it
283
+ def wire_class?(name) = name == "T::Boolean" || WIRE_CLASSES.any? { |native| native.name == name }
284
+
234
285
  # Kernel's conversion functions are how a wire value becomes one of these
235
286
  # — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
236
287
  # read a decimal string all along. Only for a type the wire can't already
237
288
  # be: Kernel#String and Kernel#Array wrap a value rather than convert it.
238
289
  def kernel_cast
239
290
  return unless Kernel.private_method_defined?(@type.to_sym)
240
- return if WIRE_CLASSES.any? { |native| native.name == @type }
291
+ return if wire_class?(@type)
241
292
 
242
293
  ->(type, expr) { "#{type}(#{expr})" }
243
294
  end
@@ -373,12 +424,15 @@ class GraphWeaver::Codegen
373
424
  # Pre-registered scalars — ordinary entries in the one registry, so a
374
425
  # later register_scalar overrides any of them.
375
426
  #
376
- # The five the spec names stay pass-through: their Ruby classes (String,
377
- # Integer) define neither .parse nor .load, so inference matches nothing
378
- # and leaves them identity which is exactly why we can name them with
379
- # the real class constants. Float is the exception, and its rule lives in
380
- # STDLIB with the others, so `register_scalar "Ratio", Float` reads the
381
- # wire exactly as the built-in Float does.
427
+ # Four of the five the spec names stay pass-through coming back
428
+ # `cast: :itself` because the spec guarantees their JSON type, so
429
+ # checking it would cost a call on the commonest leaves in every response
430
+ # and refuse nothing a compliant server sends. (Going out is another
431
+ # matter: a Rails param is a String whatever the sig says, so the same
432
+ # scalars still coerce there.) A scalar an app registers as one of those
433
+ # classes is the server's own, and gets the rule in both directions.
434
+ # Float is the exception even coming back: JSON has one number type, so
435
+ # `1.0` arrives as `1` from graphql-js and from Go.
382
436
  #
383
437
  # The rest are names, not guesses: graphql-ruby ships all but DateTime as
384
438
  # its own scalars, and this library runs a graphql-ruby schema in-process.
@@ -388,11 +442,11 @@ class GraphWeaver::Codegen
388
442
  # away. Date and datetime are told apart by their Ruby type — a Date cast
389
443
  # to Time would invent a midnight the server never sent.
390
444
  def register_builtin_scalars!
391
- register_scalar "ID", String
392
- register_scalar "String", String
393
- register_scalar "Int", Integer
445
+ register_scalar "ID", String, cast: :itself
446
+ register_scalar "String", String, cast: :itself
447
+ register_scalar "Int", Integer, cast: :itself
394
448
  register_scalar "Float", Float
395
- register_scalar "Boolean", "T::Boolean"
449
+ register_scalar "Boolean", "T::Boolean", cast: :itself
396
450
  register_scalar "Date", Date
397
451
  register_scalar "ISO8601Date", Date
398
452
  register_scalar "ISO8601DateTime", Time