graph_weaver 0.6.1 → 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 +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  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 +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  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 +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  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 +40 -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 +474 -106
  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
@@ -52,15 +52,17 @@ class GraphWeaver::Codegen
52
52
  ->(klass, value) { klass.dump(value) }),
53
53
  ].freeze
54
54
 
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.
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.
59
59
  COERCERS = {
60
60
  "Integer" => "integer",
61
61
  "Float" => "float",
62
62
  "String" => "string",
63
63
  "T::Boolean" => "boolean",
64
+ "Date" => "date",
65
+ "Time" => "time",
64
66
  }.freeze
65
67
 
66
68
  # What the library already knows about a Ruby type, so registering one
@@ -72,11 +74,25 @@ class GraphWeaver::Codegen
72
74
  # more than the ISO 8601 a Date scalar carries.
73
75
  # - the file to require, so the generated source stands alone.
74
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
+
75
84
  STDLIB = {
76
85
  "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" },
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" },
80
96
  }.freeze
81
97
 
82
98
  # Everything JSON.parse can hand back. A registered type outside this set
@@ -104,10 +120,13 @@ class GraphWeaver::Codegen
104
120
  else
105
121
  GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
106
122
  end
123
+ @cast_given = cast unless cast == :itself
107
124
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
108
125
  @cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
109
126
  @serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
110
- @serialize_value = runtime_serialize(serialize || known[:serialize], codec)
127
+ @serialize_value = (known[:call] if serialize.nil?) ||
128
+ runtime_serialize(serialize || known[:serialize], codec)
129
+ warn_half_a_value_object
111
130
  end
112
131
 
113
132
  def cast(expr) = @cast&.call(expr)
@@ -128,21 +147,74 @@ class GraphWeaver::Codegen
128
147
 
129
148
  # The code that normalizes a loose input — a Rails param — into this
130
149
  # 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.
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.
135
159
  def coerce_input(expr)
136
- if cast?
137
- "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
138
- elsif (fn = coercer)
139
- "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"
140
186
  end
141
187
  end
142
188
 
143
189
  private
144
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
+
145
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?
146
218
  return "id" if @graphql_name == "ID" && @type == "String"
147
219
 
148
220
  COERCERS[@type]
@@ -170,13 +242,30 @@ class GraphWeaver::Codegen
170
242
  ->(type, expr) { "#{type}(#{expr})" }
171
243
  end
172
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
+
173
262
  # nil infers via the matched codec; :itself opts out (identity); a
174
263
  # Symbol is a class method on the type — Money.parse(expr)
175
264
  def normalize_cast(cast, inferred)
176
265
  case cast
177
266
  when :itself then nil
178
267
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
179
- when Proc then cast
268
+ when Proc then source_builder!(:cast, cast)
180
269
  when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
181
270
  else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
182
271
  end
@@ -189,7 +278,7 @@ class GraphWeaver::Codegen
189
278
  case serialize
190
279
  when :itself then nil
191
280
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
192
- when Proc then serialize
281
+ when Proc then source_builder!(:serialize, serialize)
193
282
  when Symbol then ->(expr) { "#{expr}.#{serialize}" }
194
283
  when Array
195
284
  method, *args = serialize
@@ -238,7 +327,10 @@ class GraphWeaver::Codegen
238
327
  end
239
328
  end
240
329
  end
330
+ end
241
331
 
332
+ # The scalar half of one graph's registrations — see Codegen::Registry.
333
+ class Registry
242
334
  # Register (or override) how a GraphQL custom scalar deserializes into
243
335
  # a Ruby object and serializes back onto the wire. See ScalarType for
244
336
  # the accepted cast:/serialize:/requires: forms. Later registrations
@@ -284,10 +376,9 @@ class GraphWeaver::Codegen
284
376
  # The five the spec names stay pass-through: their Ruby classes (String,
285
377
  # Integer) define neither .parse nor .load, so inference matches nothing
286
378
  # 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.
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.
291
382
  #
292
383
  # The rest are names, not guesses: graphql-ruby ships all but DateTime as
293
384
  # its own scalars, and this library runs a graphql-ruby schema in-process.
@@ -300,7 +391,7 @@ class GraphWeaver::Codegen
300
391
  register_scalar "ID", String
301
392
  register_scalar "String", String
302
393
  register_scalar "Int", Integer
303
- register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
394
+ register_scalar "Float", Float
304
395
  register_scalar "Boolean", "T::Boolean"
305
396
  register_scalar "Date", Date
306
397
  register_scalar "ISO8601Date", Date
@@ -314,18 +405,20 @@ class GraphWeaver::Codegen
314
405
  # untyped on purpose: registering it says so, rather than leaving JSON
315
406
  # in the "unregistered custom scalars" report every generation
316
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
317
411
  end
318
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
319
419
  end
320
420
 
321
421
  # codegen's own record of a registration; users get one back from
322
422
  # `.scalar` but never name the class
323
423
  private_constant :ScalarType
324
-
325
- register_builtin_scalars!
326
-
327
- # Pre-registered rather than user intent, so generation doesn't hold a schema
328
- # to them (validate_registration! skips these). Read off the registry the line
329
- # above just filled: a seventh built-in shouldn't have to be named twice.
330
- BUILTIN_SCALARS = scalar_registry.keys.freeze
331
424
  end
@@ -11,16 +11,18 @@
11
11
  # path is resolved against an actual selection).
12
12
 
13
13
  class GraphWeaver::Codegen
14
- class << self
14
+ # The extend_type half of one graph's registrations — see Codegen::Registry.
15
+ class Registry
15
16
  # Attach app-owned helper modules to every struct generated from a
16
17
  # GraphQL type — the logic stays in your code, generation wires it in:
17
18
  #
18
19
  # GraphWeaver.extend_type("Pet", PetHelpers)
19
20
  #
20
21
  # Or build the mixin inline — the block is module_eval'd into a fresh
21
- # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
22
- # decoration; srb tc can't see into block-defined methods, so prefer
23
- # a named module where static checking matters:
22
+ # module named for where it is written and what it extends:
23
+ # GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing.
24
+ # Handy for quick decoration; srb tc can't see into block-defined methods,
25
+ # so prefer a named module where static checking matters:
24
26
  #
25
27
  # GraphWeaver.extend_type("Pet") do
26
28
  # def display_name = "#{name} the pet"
@@ -118,19 +120,62 @@ class GraphWeaver::Codegen
118
120
  # generated files may still name them.
119
121
  def reset_type_helpers!
120
122
  type_registry.clear
123
+ helper_counts.clear
121
124
  self
122
125
  end
123
126
 
124
- # a block-built mixin needs a name generated files can reference:
125
- # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
127
+ # Which graph's registrations this registry holds nil for the top-level
128
+ # one. Block-built helpers are named for it, so two graphs extending the
129
+ # same type get two constants (see helper_module).
130
+ attr_accessor :graph_name
131
+
132
+ # A block-built mixin needs a name generated files can reference:
133
+ # GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing (V2,
134
+ # V3… for a second and third block on the same type in the same place).
135
+ #
136
+ # The name is a function of the source and nothing else — where the block
137
+ # is written and what it extends. It gets baked into generated code, so
138
+ # naming it after whichever constants happened to exist made it a function
139
+ # of how many times THIS process had read the registry, and `generate`
140
+ # wrote a name a plain boot never creates.
126
141
  def helper_module(graphql_name, block)
127
- base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
128
- name = base
129
- count = 1
130
- name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
131
- GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
142
+ namespace = helper_namespace
143
+ type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
144
+ index = (helper_counts[[namespace.name, type]] += 1)
145
+ name = index == 1 ? type : "#{type}V#{index}"
146
+ # reused rather than replaced, so re-declaring the same source (a Rails
147
+ # to_prepare reload) keeps the module already-loaded structs include
148
+ mod = const_under(namespace, name) { Module.new }
149
+ mod.module_eval(&block)
150
+ mod
132
151
  end
133
152
  private :helper_module
153
+
154
+ # Where this registry's block-built helpers live: under a module named for
155
+ # the graph, so two graphs extending the same type get two constants and
156
+ # neither has to know the other exists.
157
+ def helper_namespace
158
+ return GraphWeaver::TypeHelpers unless graph_name
159
+
160
+ const_under(GraphWeaver::TypeHelpers, GraphWeaver::Inflect.camelize(graph_name.to_s)) { Module.new }
161
+ end
162
+ private :helper_namespace
163
+
164
+ def const_under(namespace, name)
165
+ return namespace.const_get(name, false) if namespace.const_defined?(name, false)
166
+
167
+ namespace.const_set(name, yield)
168
+ end
169
+ private :const_under
170
+
171
+ # How many block-built helpers this registry has already named for a type.
172
+ # Per registry, not per process: a graph's registrations are replayed over
173
+ # a fresh copy of the top-level registry on every read, so the same source
174
+ # counts the same way every time.
175
+ def helper_counts
176
+ @helper_counts ||= Hash.new(0)
177
+ end
178
+ private :helper_counts
134
179
  end
135
180
  end
136
181