graph_weaver 0.6.0 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. metadata +56 -1
@@ -158,8 +158,12 @@ class GraphWeaver::Codegen
158
158
  else
159
159
  "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
160
160
  end
161
+ return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
161
162
 
162
- "#{expr}.map { |#{var}| #{inner} }"
163
+ # the index is a path segment — `where._and.0._not.species` needs the 0
164
+ # to name one form field
165
+ idx = "i#{depth}"
166
+ "#{expr}.map.with_index { |#{var}, #{idx}| GraphWeaver::InputStruct.element(#{idx}, #{var}) { #{inner} } }"
163
167
  end
164
168
 
165
169
  def hash_coerce_identity? = @of.hash_coerce_identity?
@@ -190,6 +194,11 @@ class GraphWeaver::Codegen
190
194
  "#{class_name}.from_h(#{expr})"
191
195
  end
192
196
 
197
+ # a composite renders itself — #as_json is the mirror of .from_h
198
+ def serialize(expr, _depth)
199
+ "#{expr}.as_json"
200
+ end
201
+
193
202
  def nested = self
194
203
  end
195
204
 
@@ -253,8 +262,16 @@ class GraphWeaver::Codegen
253
262
  end
254
263
  end
255
264
 
265
+ # The fallback member is in no wire table — several wire values collapse
266
+ # into it, so `invert` keeps none — and it is exactly the member a drifted
267
+ # response casts to. Its own #serialize is the only spelling left, and it
268
+ # casts back to the fallback, so a result still round-trips through
269
+ # #as_json. Without a fallback the table is total and a miss is a real
270
+ # mistake, so it still raises.
256
271
  def serialize(expr, _depth)
257
- "#{const_prefix}_TO_WIRE.fetch(#{expr})"
272
+ return "#{const_prefix}_TO_WIRE.fetch(#{expr})" unless @fallback
273
+
274
+ "#{const_prefix}_TO_WIRE.fetch(#{expr}) { |member| member.serialize }"
258
275
  end
259
276
 
260
277
  def leaf? = true
@@ -298,6 +315,8 @@ class GraphWeaver::Codegen
298
315
  end
299
316
  end
300
317
 
318
+ def serialize(expr, depth) = @of.serialize(expr, depth)
319
+ def serialize_identity? = @of.serialize_identity?
301
320
  def nested = @of
302
321
  end
303
322
 
@@ -306,10 +325,13 @@ class GraphWeaver::Codegen
306
325
  # alphabetically first of their keys, which the walk may reach second
307
326
  attr_accessor :class_name
308
327
  attr_reader :members # graphql type name => ObjectNode
309
- # the struct an unnamed (or newly-added) __typename deserializes into
328
+ # The struct an unnamed (or newly-added) __typename deserializes into.
329
+ # Required, not defaulted: a dispatch without one has to refuse a member
330
+ # the schema grew, and "the query named every member today" is not a
331
+ # reason to break tomorrow's response.
310
332
  attr_reader :catch_all
311
333
 
312
- def initialize(class_name, members, catch_all = nil)
334
+ def initialize(class_name, members, catch_all)
313
335
  @class_name = class_name
314
336
  @members = members
315
337
  @catch_all = catch_all
@@ -321,6 +343,10 @@ class GraphWeaver::Codegen
321
343
  "#{class_name}.from_h(#{expr})"
322
344
  end
323
345
 
346
+ def serialize(expr, _depth)
347
+ "#{expr}.as_json"
348
+ end
349
+
324
350
  def nested = self
325
351
  end
326
352
 
@@ -342,6 +368,10 @@ class GraphWeaver::Codegen
342
368
  def cast(expr, _depth)
343
369
  "#{class_name}.from_h(#{expr})"
344
370
  end
371
+
372
+ def serialize(expr, _depth)
373
+ "#{expr}.as_json"
374
+ end
345
375
  end
346
376
 
347
377
  # An input-object variable: emitted as a module-level T::Struct whose
@@ -351,14 +381,17 @@ class GraphWeaver::Codegen
351
381
  class InputNode < Node
352
382
  Field = Struct.new(:prop, :wire, :node, :required)
353
383
 
354
- attr_reader :class_name, :fields
384
+ # graphql_name as well as class_name: a schema coordinate is spelled the
385
+ # schema's way (pokemon_bool_exp.name), which camelize has already lost.
386
+ attr_reader :class_name, :graphql_name, :fields
355
387
  # @oneOf: exactly one field may be supplied. The schema can't say so — every
356
388
  # @oneOf field is nullable — so the generated struct carries the flag and
357
389
  # InputStruct#serialize enforces it.
358
390
  attr_accessor :one_of
359
391
 
360
- def initialize(class_name)
392
+ def initialize(class_name, graphql_name = class_name)
361
393
  @class_name = class_name
394
+ @graphql_name = graphql_name
362
395
  @fields = []
363
396
  @one_of = false
364
397
  end
@@ -0,0 +1,175 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # One graph's registrations: the scalar codecs, the enum mappings, and the
5
+ # type helpers a generation reads. The three tables move together because a
6
+ # registration is scoped to the schema it describes — an app with two schemas
7
+ # registers `Money` for each, or for only one, and neither answer is the
8
+ # other's (see GraphWeaver.graph).
9
+ #
10
+ # The methods themselves live in Registrations, which the three codegen/*.rb
11
+ # files fill in; this is where they get somewhere to write. Codegen's class
12
+ # methods (GraphWeaver.register_scalar and friends) delegate here, to the
13
+ # default graph's registry — so a single-schema app never meets this class.
14
+
15
+ class GraphWeaver::Codegen
16
+ # The registrations one graph generates with: the scalar codecs, the enum
17
+ # mappings and the type helpers, plus what a schema can say about them.
18
+ # Not API (private_constant below) — an app writes register_scalar/
19
+ # register_enum/extend_type, at the top level (the default graph) or in a
20
+ # graph block, and never names the object holding them.
21
+ # codegen/{scalar,enum,type_helpers}.rb fill in the registering half.
22
+ class Registry
23
+ # What a registry's names must be in the schema. extend_type decorates
24
+ # whatever composite a query reaches, so it demands no particular kind.
25
+ REGISTERED_KIND = { "scalar" => "SCALAR", "enum" => "ENUM" }.freeze
26
+ # the type registry is reached via extend_type; scalars/enums via register_*
27
+ REGISTRATION_METHOD = { "type" => "extend_type", "scalar" => "register_scalar", "enum" => "register_enum" }.freeze
28
+ private_constant :REGISTERED_KIND, :REGISTRATION_METHOD
29
+
30
+ # Every registration this schema can't match, one sentence each. The answer
31
+ # depends on the schema and the registry alone, not on any one document, so
32
+ # a whole generate! run gets the same list — which is what lets the build
33
+ # report it once (see GraphWeaver.unmatched_registrations).
34
+ #
35
+ # The built-in scalars are pre-registered entries in the same table rather
36
+ # than user intent, so they're exempt — a schema with no Date scalar is not
37
+ # a mistake.
38
+ def unmatched_registrations(schema)
39
+ {
40
+ "enum" => enum_registry,
41
+ "scalar" => scalar_registry.except(*BUILTIN_SCALARS),
42
+ "type" => type_registry,
43
+ }.flat_map do |kind, registry|
44
+ registry.keys.filter_map { |name| validate_registration!(schema, kind, name) }
45
+ end
46
+ end
47
+
48
+ # A registry serves one graph, but a generation sees one schema — so a
49
+ # registration fails generation only where THIS schema can disprove it: a
50
+ # name it declares as something else, or a coordinate whose field it declares
51
+ # as a composite. A name it can't match at all proves nothing, because an
52
+ # entity type is declared by every subgraph that references it while its
53
+ # fields are split among them; that returns the sentence to say instead.
54
+ def validate_registration!(schema, kind, name)
55
+ method = REGISTRATION_METHOD.fetch(kind)
56
+ # register_scalar("Type.field", ...) overrides one field's scalar — validate
57
+ # the field, not that a type named "Type.field" exists.
58
+ return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
59
+
60
+ type = schema.get_type(name)
61
+ unless type
62
+ return unmatched(schema, method, name, kind, GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, name))
63
+ end
64
+
65
+ expected = REGISTERED_KIND[kind]
66
+ return if expected.nil? || type.kind.name == expected
67
+
68
+ found = type.kind.name.downcase.tr("_", " ")
69
+ # a leaf registered as the other kind has a method that would have worked
70
+ other = REGISTERED_KIND.key(type.kind.name)
71
+ raise GraphWeaver::Error,
72
+ "#{method}(#{name.inspect}) names #{article(found)} #{found}, not #{article(kind)} " \
73
+ "#{kind}#{other ? " — use #{REGISTRATION_METHOD.fetch(other)}" : ""}"
74
+ end
75
+ private :validate_registration!
76
+
77
+ # A per-field override, register_scalar("Type.field", ...). Neither an absent
78
+ # type nor an absent field is disprovable here; what is, is a field this
79
+ # schema declares as something a scalar codec could never read.
80
+ def validate_scalar_field!(schema, name, method)
81
+ type_name, field_name = name.split(".", 2)
82
+ type = schema.get_type(type_name)
83
+ unless type
84
+ near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
85
+ return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
86
+ end
87
+
88
+ fields = type.respond_to?(:fields) ? type.fields : {}
89
+ field = fields[field_name]
90
+ unless field
91
+ near = GraphWeaver::Internal::Util.did_you_mean(fields.keys, field_name)
92
+ return unmatched(schema, method, name, "scalar field", near && "#{type_name}.#{near}")
93
+ end
94
+ return if field.type.unwrap.kind.name == "SCALAR"
95
+
96
+ raise GraphWeaver::Error,
97
+ "#{method}(#{name.inspect}): #{name} isn't a scalar field (it's #{field.type.unwrap.kind.name.downcase})"
98
+ end
99
+ private :validate_scalar_field!
100
+
101
+ # What to say about a name this schema has nothing for. Registrations are
102
+ # graph-scoped — federation composes by name, so one `Money` codec serves
103
+ # every subgraph that declares it — which is exactly why this schema can't
104
+ # tell a typo from a registration for the subgraph next door. Say both.
105
+ def unmatched(schema, method, name, what, suggestion)
106
+ hint = suggestion ? " (did you mean '#{suggestion}'?)" : ""
107
+ "#{method}(#{name.inspect}) matches no #{what} in #{schema.name || "this schema"} " \
108
+ "— a typo#{hint}, or a registration for another schema"
109
+ end
110
+ private :unmatched
111
+
112
+ def article(word) = GraphWeaver::Internal::Util.article(word)
113
+ private :article
114
+
115
+ # Every table back to its starting state — scalars (built-ins restored),
116
+ # enum mappings, and type helpers. The clean slate between tests, and the
117
+ # one call that stays right when a fourth kind of registration shows up.
118
+ def reset_registrations!
119
+ reset_scalars!
120
+ reset_enums!
121
+ reset_type_helpers!
122
+ self
123
+ end
124
+
125
+ def initialize = register_builtin_scalars!
126
+
127
+ # A graph starts from the top-level registrations and adds its own, so the
128
+ # three tables are copied rather than shared — an app that registered Money
129
+ # before it had a second schema keeps it, and a graph block can't reach back.
130
+ def initialize_copy(other)
131
+ super
132
+ @scalar_registry = other.scalar_registry.dup
133
+ @enum_registry = other.enum_registry.dup
134
+ # the entry is a hash of mutable arrays, so each one is copied too
135
+ @type_registry = other.type_registry.transform_values { |e| e.transform_values(&:dup) }
136
+ @helper_counts = other.send(:helper_counts).dup
137
+ end
138
+ end
139
+
140
+ # The default graph's registrations — where a top-level
141
+ # GraphWeaver.register_scalar writes, and what a generation uses unless a
142
+ # graph hands it its own.
143
+ def self.registry = @registry ||= Registry.new
144
+
145
+ # Pre-registered rather than user intent, so generation doesn't hold a schema
146
+ # to them (validate_registration! skips these). Read off a fresh registry: a
147
+ # seventh built-in shouldn't have to be named twice.
148
+ BUILTIN_SCALARS = Registry.new.scalar_registry.keys.freeze
149
+
150
+ class << self
151
+ # The default graph's registry answers every one of these — the surface an
152
+ # app has used since before graphs existed, unchanged.
153
+ def register_scalar(...) = registry.register_scalar(...)
154
+ def register_enum(...) = registry.register_enum(...)
155
+ def extend_type(...) = registry.extend_type(...)
156
+ def scalar(...) = registry.scalar(...)
157
+ def scalar_registry = registry.scalar_registry
158
+ def enum_registry = registry.enum_registry
159
+ def type_registry = registry.type_registry
160
+ def unmatched_registrations(...) = registry.unmatched_registrations(...)
161
+ def clear_scalars! = registry.clear_scalars! && self
162
+ def reset_scalars! = registry.reset_scalars! && self
163
+ def reset_enums! = registry.reset_enums! && self
164
+ def reset_type_helpers! = registry.reset_type_helpers! && self
165
+
166
+ # Returns Codegen, not the registry: these are the documented calls, and
167
+ # their value has always been something you can keep chaining off.
168
+ def reset_registrations! = registry.reset_registrations! && self
169
+ end
170
+
171
+ # Nothing outside the gem names it, and it can't move under Internal either:
172
+ # its methods build a ScalarType and an EnumType, which are private here.
173
+ # Private at load, not in a method body — see spec/registry_spec.rb.
174
+ private_constant :Registry
175
+ end
@@ -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
@@ -49,17 +52,55 @@ class GraphWeaver::Codegen
49
52
  ->(klass, value) { klass.dump(value) }),
50
53
  ].freeze
51
54
 
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.
55
+ # A scalar whose registration named no `cast:` of its own: its Ruby type
56
+ # is the whole rule, so key on that — a custom scalar registered as a
57
+ # plain String gets the same check. ID is the exception GraphQL itself
58
+ # names (see Coerce.id), matched by GraphQL name in #coercer.
56
59
  COERCERS = {
57
60
  "Integer" => "integer",
58
61
  "Float" => "float",
59
62
  "String" => "string",
60
63
  "T::Boolean" => "boolean",
64
+ "Date" => "date",
65
+ "Time" => "time",
61
66
  }.freeze
62
- private_constant :Codec, :CODECS, :COERCERS
67
+
68
+ # What the library already knows about a Ruby type, so registering one
69
+ # takes nothing but the class. Consulted only where the registration is
70
+ # silent; an explicit cast:/serialize:/requires: wins. Two things the
71
+ # probe above can't work out on its own:
72
+ # - the wire spelling. BigDecimal#to_s writes "0.125e2", which is not
73
+ # what any server means by 12.5, and Date.parse reads a great deal
74
+ # more than the ISO 8601 a Date scalar carries.
75
+ # - the file to require, so the generated source stands alone.
76
+ # Only types whose wire form is unambiguous belong here.
77
+ #
78
+ # `call:` is the runnable twin of a Proc `serialize:`, which builds source
79
+ # and so can't be run — the testing harness needs both (see
80
+ # #serialize_value).
81
+ TIMESTAMP = ->(expr) { "GraphWeaver::Coerce.timestamp(#{expr})" }
82
+ TIMESTAMP_CALL = ->(value) { GraphWeaver::Coerce.timestamp(value) }
83
+
84
+ STDLIB = {
85
+ "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
+ # strftime, not #iso8601: DateTime < Date passes the is_a? guard, and its
90
+ # #iso8601 writes a timestamp where the schema said a date goes
91
+ "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" },
95
+ "DateTime" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "date" },
96
+ }.freeze
97
+
98
+ # Everything JSON.parse can hand back. A registered type outside this set
99
+ # has to be BUILT from one of them, which is what a cast is for (and what
100
+ # Codegen#refuse_uncastable! insists on).
101
+ WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
102
+
103
+ private_constant :Codec, :CODECS, :COERCERS, :STDLIB
63
104
 
64
105
  attr_reader :graphql_name, :type, :requires
65
106
 
@@ -67,14 +108,25 @@ class GraphWeaver::Codegen
67
108
  @graphql_name = graphql_name.to_s
68
109
  @klass = type.is_a?(Module) ? type : nil
69
110
  @type = type_name(type)
70
- # requires: load BEFORE codec probing — the probe method may come
71
- # from the required file (core Time has no .parse until the "time"
72
- # stdlib loads)
73
- @requires = normalize_requires(requires)
111
+ known = STDLIB[@type] || {}
112
+ # requires: load BEFORE probing the deserializer may arrive with the
113
+ # file (core Time has no .parse until the "time" stdlib loads, and
114
+ # Kernel#BigDecimal none until "bigdecimal" does). A path from STDLIB
115
+ # is the library's own, so it loads even for a type: given as a String,
116
+ # whose dependency we otherwise can't assume is installed.
117
+ @requires =
118
+ if requires.nil?
119
+ GraphWeaver::Codegen.normalize_requires!(known[:requires], load: true)
120
+ else
121
+ GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
122
+ end
123
+ @cast_given = cast unless cast == :itself
74
124
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
75
- @cast = normalize_cast(cast, codec&.cast)
76
- @serialize = normalize_serialize(serialize, codec&.serialize)
77
- @serialize_value = runtime_serialize(serialize, codec)
125
+ @cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
126
+ @serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
127
+ @serialize_value = (known[:call] if serialize.nil?) ||
128
+ runtime_serialize(serialize || known[:serialize], codec)
129
+ warn_half_a_value_object
78
130
  end
79
131
 
80
132
  def cast(expr) = @cast&.call(expr)
@@ -95,21 +147,74 @@ class GraphWeaver::Codegen
95
147
 
96
148
  # The code that normalizes a loose input — a Rails param — into this
97
149
  # 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.
150
+ # The Ruby type's own rule in Coerce is the check, which is what
151
+ # `.checked(:never)` on the generated sig gives up. A registration that
152
+ # named its own `cast:` says how to build the Ruby object instead but
153
+ # the guard in front of it and the verdict when it refuses are still the
154
+ # library's, so both go through Coerce.cast rather than a bare `is_a?`
155
+ # (a DateTime is one of those, and is not a Date on any wire).
156
+ # The schema's name for the scalar travels with the value, so a refusal
157
+ # reports GraphQL vocabulary rather than the Ruby type it maps to —
158
+ # register_scalar("Money", BigDecimal) refuses a Money, not a BigDecimal.
102
159
  def coerce_input(expr)
103
- if cast?
104
- "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
105
- elsif (fn = coercer)
106
- "GraphWeaver::Coerce.#{fn}(#{expr})"
160
+ if (fn = coercer)
161
+ "GraphWeaver::Coerce.#{fn}(#{expr}, #{@graphql_name.inspect})"
162
+ elsif cast?
163
+ "GraphWeaver::Coerce.cast(#{@type}, #{expr}, #{@graphql_name.inspect}) { |raw| #{cast("raw")} }"
164
+ end
165
+ end
166
+
167
+ # Why this registration can't read the JSON at `where`, and what to do about
168
+ # it. Two different mistakes land here, so say which: a type: given by name
169
+ # was never probed (there is no class in hand to probe), while a class was
170
+ # probed and matched nothing. Raised by Codegen#refuse_uncastable!, which
171
+ # owns the question of whether the wire could satisfy the prop at all.
172
+ def uncastable_message(where)
173
+ article = GraphWeaver::Internal::Util.article(@type)
174
+ head = "register_scalar(#{@graphql_name.inspect}, #{@klass ? @type : @type.inspect}) has no " \
175
+ "cast, so nothing builds #{article} #{@type} out of the JSON at #{where}"
176
+ if @klass.nil?
177
+ "#{head} — a type: given by name is never probed, since there is no class in hand. Pass " \
178
+ "the class (register_scalar(#{@graphql_name.inspect}, #{@type})) to infer a cast from " \
179
+ "it, or name one yourself (cast: :parse names a class method, " \
180
+ "cast: ->(v) { \"#{@type}.parse(\#{v})\" } emits any expression)"
181
+ else
182
+ "#{head} — #{@type} defines no .parse and no .load, and Kernel has no #{@type} conversion " \
183
+ "function, so there was nothing to infer. Give it a cast (cast: :parse names a class " \
184
+ "method, cast: ->(v) { \"#{@type}.new(\#{v})\" } emits any expression), or register a " \
185
+ "type the wire already parses into"
107
186
  end
108
187
  end
109
188
 
110
189
  private
111
190
 
191
+ # A result compares its props with eql?, so that it and #hash agree on what
192
+ # "same" means. A type that leaves eql? at Object's compares by identity,
193
+ # so two results parsed from the same bytes are unequal and useless as hash
194
+ # keys — whether it defined == (the common Ruby idiom, and the leaf itself
195
+ # then compares fine) or no equality at all. Nothing here can fix that;
196
+ # only the type can.
197
+ def warn_half_a_value_object
198
+ # a Module type names a duck the gem never sees an instance of; a T::Enum's
199
+ # values are singletons, so identity already is equality
200
+ return unless @klass.is_a?(Class) && !(@klass < T::Enum) && !defines?(:eql?)
201
+
202
+ GraphWeaver::Internal::Log.log(:warn) do
203
+ "register_scalar(#{@graphql_name.inspect}, #{@type}): #{@type} inherits #eql? and #hash, " \
204
+ "so its instances compare by identity — two results parsed from the same response won't " \
205
+ "be equal and a result won't work as a hash key — define ==, eql? and hash off the same " \
206
+ "values (alias_method :eql?, :== is the usual shortcut)"
207
+ end
208
+ end
209
+
210
+ def defines?(method)
211
+ ![BasicObject, Kernel, Object].include?(@klass.instance_method(method).owner)
212
+ end
213
+
112
214
  def coercer
215
+ # a cast: the registration named is the whole rule (:itself asks for
216
+ # no rich object, which is not the same as asking for no check)
217
+ return unless @cast_given.nil?
113
218
  return "id" if @graphql_name == "ID" && @type == "String"
114
219
 
115
220
  COERCERS[@type]
@@ -126,45 +231,77 @@ class GraphWeaver::Codegen
126
231
  end
127
232
  end
128
233
 
234
+ # Kernel's conversion functions are how a wire value becomes one of these
235
+ # — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
236
+ # read a decimal string all along. Only for a type the wire can't already
237
+ # be: Kernel#String and Kernel#Array wrap a value rather than convert it.
238
+ def kernel_cast
239
+ return unless Kernel.private_method_defined?(@type.to_sym)
240
+ return if WIRE_CLASSES.any? { |native| native.name == @type }
241
+
242
+ ->(type, expr) { "#{type}(#{expr})" }
243
+ end
244
+
245
+ # A Proc here builds SOURCE for the generated file, so a proc that
246
+ # converts a value (`->(v) { v.to_sym }`) can't work — it interpolates to
247
+ # nothing and every response fails far from the registration. Probe it
248
+ # once now, where the message can name the spelling.
249
+ def source_builder!(option, proc)
250
+ probe = proc.arity.zero? ? proc.call : proc.call("v")
251
+ return proc if probe.is_a?(String)
252
+
253
+ raise ArgumentError, "#{option}: a Proc must return the Ruby source to emit — " \
254
+ "e.g. #{option}: ->(v) { \"#{@type}.parse(\#{v})\" } — got #{probe.inspect}; " \
255
+ "a Symbol names a method instead (#{option}: :parse)"
256
+ rescue ArgumentError => e
257
+ raise if e.message.start_with?("#{option}:")
258
+
259
+ raise ArgumentError, "#{option}: a Proc takes one argument, the expression to wrap — #{e.message}"
260
+ end
261
+
129
262
  # nil infers via the matched codec; :itself opts out (identity); a
130
263
  # Symbol is a class method on the type — Money.parse(expr)
131
264
  def normalize_cast(cast, inferred)
132
265
  case cast
133
266
  when :itself then nil
134
267
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
135
- when Proc then cast
268
+ when Proc then source_builder!(:cast, cast)
136
269
  when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
137
270
  else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
138
271
  end
139
272
  end
140
273
 
141
- # nil infers via the matched codec; :itself opts out (identity); a
142
- # Symbol is an instance method on the value — expr.to_s
274
+ # nil infers via the matched codec; :itself opts out (identity); a Symbol
275
+ # is an instance method on the value — expr.to_s — and an Array is that
276
+ # method with arguments: [:to_s, "F"] => expr.to_s("F")
143
277
  def normalize_serialize(serialize, inferred)
144
278
  case serialize
145
279
  when :itself then nil
146
280
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
147
- when Proc then serialize
281
+ when Proc then source_builder!(:serialize, serialize)
148
282
  when Symbol then ->(expr) { "#{expr}.#{serialize}" }
149
- else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
283
+ when Array
284
+ method, *args = serialize
285
+ unless method.is_a?(Symbol)
286
+ # a syntax error in the generated file otherwise
287
+ raise ArgumentError, "serialize: an Array is [method, *arguments], got #{serialize.inspect}"
288
+ end
289
+
290
+ ->(expr) { "#{expr}.#{method}(#{args.map(&:inspect).join(", ")})" }
291
+ else raise ArgumentError, "serialize: must be a Symbol, Array, Proc, :itself, or nil, got #{serialize.inspect}"
150
292
  end
151
293
  end
152
294
 
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.
295
+ # The runnable half of normalize_serialize: a Symbol (or Symbol with
296
+ # arguments) names a method — :itself included, which is identity either
297
+ # way — an inferred codec knows its own call, and a Proc emits code there
298
+ # is no way to run.
156
299
  def runtime_serialize(serialize, codec)
157
300
  case serialize
158
- when Symbol then ->(value) { value.public_send(serialize) }
301
+ when Symbol, Array then ->(value) { value.public_send(*serialize) }
159
302
  when nil then codec && ->(value) { codec.call.call(@klass, value) }
160
303
  end
161
304
  end
162
-
163
- # With only a type-name string we can't assume the lib is installed at
164
- # codegen time, so the paths aren't loaded — only shape-checked.
165
- def normalize_requires(requires)
166
- GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
167
- end
168
305
  end
169
306
 
170
307
  class << self
@@ -190,7 +327,10 @@ class GraphWeaver::Codegen
190
327
  end
191
328
  end
192
329
  end
330
+ end
193
331
 
332
+ # The scalar half of one graph's registrations — see Codegen::Registry.
333
+ class Registry
194
334
  # Register (or override) how a GraphQL custom scalar deserializes into
195
335
  # a Ruby object and serializes back onto the wire. See ScalarType for
196
336
  # the accepted cast:/serialize:/requires: forms. Later registrations
@@ -230,36 +370,55 @@ class GraphWeaver::Codegen
230
370
  self
231
371
  end
232
372
 
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.
373
+ # Pre-registered scalars — ordinary entries in the one registry, so a
374
+ # later register_scalar overrides any of them.
375
+ #
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.
382
+ #
383
+ # The rest are names, not guesses: graphql-ruby ships all but DateTime as
384
+ # its own scalars, and this library runs a graphql-ruby schema in-process.
385
+ # DateTime is what GitHub, Shopify and most hand-written schemas call an
386
+ # ISO 8601 timestamp; a schema that means something else by it fails
387
+ # loudly (the cast raises, naming the field) and is one register_scalar
388
+ # away. Date and datetime are told apart by their Ruby type — a Date cast
389
+ # to Time would invent a midnight the server never sent.
244
390
  def register_builtin_scalars!
245
391
  register_scalar "ID", String
246
392
  register_scalar "String", String
247
393
  register_scalar "Int", Integer
248
- register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
394
+ register_scalar "Float", Float
249
395
  register_scalar "Boolean", "T::Boolean"
250
- register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
396
+ register_scalar "Date", Date
397
+ register_scalar "ISO8601Date", Date
398
+ register_scalar "ISO8601DateTime", Time
399
+ register_scalar "DateTime", Time
400
+ # graphql-ruby writes a BigInt as a string, since JSON numbers stop
401
+ # being exact at 2^53 — so read either spelling and write the one the
402
+ # server does.
403
+ register_scalar "BigInt", Integer,
404
+ cast: ->(expr) { "GraphWeaver::Coerce.integer(#{expr})" }, serialize: :to_s
405
+ # untyped on purpose: registering it says so, rather than leaving JSON
406
+ # in the "unregistered custom scalars" report every generation
407
+ register_scalar "JSON", "T.untyped"
408
+ # the objects, not the names: a later register_scalar("DateTime", ...)
409
+ # replaces the entry, and that is app intent rather than a pre-registration
410
+ @builtin_entries = scalar_registry.values.freeze
251
411
  end
252
412
  private :register_builtin_scalars!
413
+
414
+ # Whether this name still holds the entry pre-registration put there.
415
+ def builtin_scalar?(name)
416
+ entry = scalar_registry[name]
417
+ @builtin_entries.any? { |builtin| builtin.equal?(entry) }
418
+ end
253
419
  end
254
420
 
255
421
  # codegen's own record of a registration; users get one back from
256
422
  # `.scalar` but never name the class
257
423
  private_constant :ScalarType
258
-
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
265
424
  end