graph_weaver 0.7.2 → 0.7.4

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.
@@ -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
@@ -45,10 +45,21 @@ class GraphWeaver::Codegen
45
45
  # fit the path just omits the accessor instead of failing generation. Use it
46
46
  # for a root-type accessor (a Query alias every query would otherwise have to
47
47
  # satisfy) or one that only fits some selections.
48
+ #
49
+ # Inside the block, `alias_field` is the same keyword said next to the
50
+ # methods that use it — one alias per line, always strict:
51
+ #
52
+ # GraphWeaver.extend_type("Widget") do
53
+ # alias_field :tag, "meta.tag"
54
+ # def shout = tag&.upcase
55
+ # end
48
56
  def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
49
- aliases = take_aliases(kw)
57
+ optional = !!kw.delete(:optional)
58
+ aliases = normalize_aliases(kw.delete(:alias), optional:)
59
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
60
+
50
61
  mixins = mixins.dup
51
- mixins << helper_module(graphql_name, block) if block
62
+ mixins << helper_module(graphql_name, block, aliases) if block
52
63
 
53
64
  raise ArgumentError, "pass one or more helper modules, a block, or alias:" if mixins.empty? && aliases.empty?
54
65
  mixins.each do |mixin|
@@ -70,15 +81,6 @@ class GraphWeaver::Codegen
70
81
  entry
71
82
  end
72
83
 
73
- # Pull alias:/optional: out of the keyword rest and normalize; any other
74
- # keyword is a typo worth flagging rather than silently dropping.
75
- def take_aliases(kw)
76
- aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
77
- raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
78
- aliases
79
- end
80
- private :take_aliases
81
-
82
84
  # accessor names and path segments are interpolated verbatim into generated
83
85
  # source, so — like module_name — they must be plain identifiers, never
84
86
  # arbitrary text that could inject code
@@ -138,7 +140,7 @@ class GraphWeaver::Codegen
138
140
  # naming it after whichever constants happened to exist made it a function
139
141
  # of how many times THIS process had read the registry, and `generate`
140
142
  # wrote a name a plain boot never creates.
141
- def helper_module(graphql_name, block)
143
+ def helper_module(graphql_name, block, aliases)
142
144
  namespace = helper_namespace
143
145
  type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
144
146
  index = (helper_counts[[namespace.name, type]] += 1)
@@ -146,11 +148,67 @@ class GraphWeaver::Codegen
146
148
  # reused rather than replaced, so re-declaring the same source (a Rails
147
149
  # to_prepare reload) keeps the module already-loaded structs include
148
150
  mod = const_under(namespace, name) { Module.new }
149
- mod.module_eval(&block)
151
+ aliases.merge!(collect_aliases(graphql_name, mod, aliases, &block))
150
152
  mod
151
153
  end
152
154
  private :helper_module
153
155
 
156
+ # Run the block with `alias_field` available — the alias: keyword said one
157
+ # line at a time — and hand back what it collected.
158
+ #
159
+ # It lives on the module's singleton for the length of the block and is
160
+ # removed after: a generated struct includes this module, and an
161
+ # `alias_field` left behind would be an instance method the wire never named.
162
+ def collect_aliases(graphql_name, mod, keyword_aliases, &block)
163
+ registry, collected = self, {}
164
+ mod.define_singleton_method(:alias_field) do |name, path = nil, **kw|
165
+ collected.merge!(registry.send(:one_alias, graphql_name, name, path, kw))
166
+ end
167
+ mod.module_eval(&block)
168
+
169
+ twice = keyword_aliases.keys & collected.keys
170
+ unless twice.empty?
171
+ raise ArgumentError, "extend_type(#{graphql_name.to_s.inspect}) declares alias " \
172
+ "#{twice.first.inspect} twice — once as alias:, once as alias_field; keep one"
173
+ end
174
+ collected
175
+ ensure
176
+ mod.singleton_class.send(:remove_method, :alias_field)
177
+ end
178
+ private :collect_aliases
179
+
180
+ ALIAS_FIELD_FORMS = %(one alias per line — alias_field "meta.tag", or alias_field :tag, "meta.tag"; ) +
181
+ %(a Hash or Array of paths goes on the alias: keyword)
182
+ private_constant :ALIAS_FIELD_FORMS
183
+
184
+ # One `alias_field` line, normalized the way the keyword's own paths are.
185
+ # The block takes no optional: — leniency has one spelling, on the keyword,
186
+ # because it is a property of the registration and not of one accessor.
187
+ def one_alias(graphql_name, name, path, kw)
188
+ if kw.key?(:optional)
189
+ keyword = path ? "alias: { #{name}: #{path.inspect} }" : "alias: #{name.inspect}"
190
+ raise ArgumentError, "alias_field is always strict — for a lenient alias use the keyword: " \
191
+ "extend_type(#{graphql_name.to_s.inspect}, #{keyword}, optional: true)"
192
+ end
193
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
194
+
195
+ input = if path.nil? && name.is_a?(String)
196
+ name
197
+ elsif path.is_a?(String) && (name.is_a?(String) || name.is_a?(Symbol))
198
+ { name => path }
199
+ end
200
+ if input.nil?
201
+ # spelled as a caller writes it — Hash#inspect changed between Ruby 3.3 and 3.4
202
+ given = [name, path].compact.map do |arg|
203
+ arg.is_a?(Hash) ? "{#{arg.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}" : arg.inspect
204
+ end.join(", ")
205
+ raise ArgumentError, "alias_field #{given}: #{ALIAS_FIELD_FORMS}"
206
+ end
207
+
208
+ normalize_aliases(input, optional: false)
209
+ end
210
+ private :one_alias
211
+
154
212
  # Where this registry's block-built helpers live: under a module named for
155
213
  # the graph, so two graphs extending the same type get two constants and
156
214
  # neither has to know the other exists.
@@ -154,6 +154,12 @@ class GraphWeaver::Codegen
154
154
  # queries so the build says them once, for GraphWeaver.untyped_scalars.
155
155
  def untyped_scalars = @untyped_scalars.uniq.sort
156
156
 
157
+ # The block-built type helpers this walk included, by constant name. They are
158
+ # minted at registration, so no source file declares them and an app's
159
+ # `srb tc` can't resolve the include this emitted — the generate! workflow
160
+ # unions these across queries and declares them in an .rbi.
161
+ def block_helpers = @block_helpers.uniq.sort
162
+
157
163
  # The shared types artifact: every type a schema shares across query modules,
158
164
  # emitted once as a manifest (types.rb) plus one file per type under types/,
159
165
  # so a schema migration diffs only the types it touched. Returns
@@ -245,6 +251,8 @@ class GraphWeaver::Codegen
245
251
  @input_hops = []
246
252
  @mapped_enums = {}
247
253
  @used_unions = []
254
+ # block-built type helpers this walk included — see #block_helpers
255
+ @block_helpers = []
248
256
  # requires the generated file needs (custom scalars, enum mappings,
249
257
  # type helpers all contribute)
250
258
  @requires = []
@@ -840,6 +848,7 @@ class GraphWeaver::Codegen
840
848
  node = ObjectNode.new(class_name)
841
849
  node.graphql_type = type.graphql_name
842
850
  node.mixins = type_mixins(type.graphql_name)
851
+ node.overrides = abstract_mixin_members(type.graphql_name)
843
852
  # class name => the result key that claimed it; the struct itself first,
844
853
  # claimed by nothing (see pick_name)
845
854
  taken = { class_name => nil }
@@ -1315,7 +1324,8 @@ class GraphWeaver::Codegen
1315
1324
  child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
1316
1325
  @input_hops.pop
1317
1326
  required = child.non_null? && !argument.default_value?
1318
- node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
1327
+ node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required,
1328
+ argument.type.to_type_signature)
1319
1329
  end
1320
1330
  check_input_props!(core, node)
1321
1331
  node
@@ -1396,15 +1406,18 @@ class GraphWeaver::Codegen
1396
1406
  "constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1397
1407
  end
1398
1408
 
1399
- EnumNode.new(class_name, enum_values(core))
1409
+ aliases = @registry.enum_registry[core.graphql_name]&.aliases_for(core.values.keys.sort) || {}
1410
+ EnumNode.new(class_name, enum_values(core, aliases), aliases)
1400
1411
  end
1401
1412
 
1402
- # A schema enum's wire values, sorted so output is deterministic across schema
1403
- # sources (SDL round-trips reorder values alphabetically). Values that differ
1404
- # only in case name the same T::Enum constant, which raises at LOAD time
1405
- # ("Enum values must be assigned to constants") catch it here instead.
1406
- def enum_values(core)
1407
- values = core.values.keys.sort
1413
+ # A schema enum's constant-bearing wire values, sorted so output is
1414
+ # deterministic across schema sources (SDL round-trips reorder values
1415
+ # alphabetically). An aliased spelling is read as another of the values, so
1416
+ # it gets no constant. Values that differ only in case name the same T::Enum
1417
+ # constant, which raises at LOAD time ("Enum values must be assigned to
1418
+ # constants") — catch it here instead.
1419
+ def enum_values(core, aliases = {})
1420
+ values = core.values.keys.sort - aliases.keys
1408
1421
  # `_` and `__` are legal GraphQL enum values and camelize to nothing, so
1409
1422
  # the emitted `= new("_")` isn't even parseable — the file fails at load
1410
1423
  # with a syntax error pointing into generated source
@@ -1415,12 +1428,18 @@ class GraphWeaver::Codegen
1415
1428
  "one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1416
1429
  end
1417
1430
 
1418
- collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
1419
- if collision
1431
+ # A schema mid-rename declares every value twice, so every pair collides:
1432
+ # name one, count the rest, and print the registration that fixes them all.
1433
+ collisions = values.group_by { |value| camelize(value.downcase) }.select { |_, group| group.size > 1 }
1434
+ if collisions.any?
1435
+ constant, group = collisions.first
1436
+ more = collisions.size - 1
1420
1437
  raise GraphWeaver::Error,
1421
- "enum #{core.graphql_name} values #{collision.last.join(" and ")} both become the constant " \
1422
- "#{collision.first} — map the enum onto one of yours: " \
1423
- "register_enum(#{core.graphql_name.inspect}, YourEnum)"
1438
+ "enum #{core.graphql_name} values #{group.join(" and ")} both become the constant #{constant}" \
1439
+ "#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
1440
+ "value, say which spelling goes on the wire:\n " \
1441
+ "#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
1442
+ "or map the enum onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1424
1443
  end
1425
1444
 
1426
1445
  values
@@ -1432,14 +1451,40 @@ class GraphWeaver::Codegen
1432
1451
  return [] unless entry
1433
1452
 
1434
1453
  @requires.concat(entry[:requires])
1435
- entry[:mixins].map(&:name)
1454
+ names = entry[:mixins].map(&:name)
1455
+ # GraphWeaver::TypeHelpers is where extend_type mints a block's mixin, and
1456
+ # a module there exists in no source file — so generation has to declare it
1457
+ @block_helpers.concat(names.grep(BLOCK_HELPER))
1458
+ names
1459
+ end
1460
+
1461
+ # A block-built mixin's constant, by the namespace extend_type mints it under.
1462
+ BLOCK_HELPER = /\AGraphWeaver::TypeHelpers::/
1463
+ private_constant :BLOCK_HELPER
1464
+
1465
+ # Struct members a registered mixin declares abstract (docs/generated_modules.md
1466
+ # recommends that shape so a named helper carries sigs srb tc can check).
1467
+ # Sorbet demands the override be declared on whatever satisfies an abstract
1468
+ # sig, and nothing generation writes can be corrected by hand — a `const` has
1469
+ # no sig to insert `override.` into — so codegen derives it, the same way it
1470
+ # derives an alias's type. Mixin ancestors count: an inherited abstract sig is
1471
+ # one Sorbet demands the override for just the same.
1472
+ def abstract_mixin_members(graphql_name)
1473
+ entry = @registry.type_registry[graphql_name]
1474
+ return [] unless entry
1475
+
1476
+ entry[:mixins].flat_map { |mixin|
1477
+ T::AbstractUtils.declared_abstract_methods_for(mixin).map { |method| method.name.to_s }
1478
+ }.uniq
1436
1479
  end
1480
+ private :abstract_mixin_members
1437
1481
 
1438
1482
  # The MappedEnum node for a schema enum with a registered app-enum
1439
- # mapping; nil when unregistered, falling back to a generated T::Enum.
1483
+ # mapping; nil when unregistered or registered for alias: alone, which
1484
+ # says nothing about the Ruby type — falling back to a generated T::Enum.
1440
1485
  def mapped_enum_node(core)
1441
1486
  enum_type = @registry.enum_registry[core.graphql_name]
1442
- return unless enum_type
1487
+ return unless enum_type&.type
1443
1488
 
1444
1489
  @requires.concat(enum_type.requires)
1445
1490
  @mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "date" # Date/DateTime, named below
5
+ require "time" # Time.iso8601
5
6
 
6
7
  require_relative "errors"
7
8
  require_relative "internal/refusal"
@@ -38,19 +39,24 @@ module GraphWeaver
38
39
  # app translates for a user. It defaults to the GraphQL scalar the rule
39
40
  # is named for; generated code passes the schema's own name, so a
40
41
  # `register_scalar("BigInt", Integer)` field refuses as a BigInt.
42
+ #
43
+ # A real number converts to the number the scalar names, and whether it
44
+ # is whole is asked of the value — so a BigDecimal off a decimal column
45
+ # and a Rational are ordinary arguments.
41
46
  def integer(value, scalar = "Int")
42
47
  case value
43
48
  when Integer then value
44
- when Float then whole(value, scalar)
49
+ when Numeric then whole(real(value, scalar), scalar)
45
50
  when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
46
51
  else refuse(value, scalar)
47
52
  end
48
53
  end
49
54
 
55
+ # Float's own precision is what asking for a Float means, so a BigDecimal
56
+ # past it loses digits here rather than being refused.
50
57
  def float(value, scalar = "Float")
51
58
  case value
52
- when Float then finite(value, scalar)
53
- when Integer then finite(value.to_f, scalar)
59
+ when Numeric then finite(real(value, scalar).to_f, scalar)
54
60
  when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
55
61
  else refuse(value, scalar)
56
62
  end
@@ -70,12 +76,16 @@ module GraphWeaver
70
76
  end
71
77
  end
72
78
 
79
+ # A timestamp string is ISO 8601, read by Time.iso8601 — the coercer
80
+ # graphql-ruby's own ISO8601DateTime reads input with, and the only
81
+ # shape its coerce_result writes. Time.parse also reads "Jan 15 2024
82
+ # 10:20", which no server sends and no generated struct should take.
73
83
  def time(value, scalar = "Time")
74
84
  case value
75
85
  when Time then value
76
86
  when DateTime then value.to_time # the same instant in another class
77
87
  when Date then cross(value, scalar, TIME_HINT)
78
- when String then parsing(scalar, value) { Time.parse(value) }
88
+ when String then parsing(scalar, value) { Time.iso8601(value) }
79
89
  else time_like?(value) ? value.to_time : refuse(value, scalar)
80
90
  end
81
91
  end
@@ -194,8 +204,17 @@ module GraphWeaver
194
204
  raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
195
205
  end
196
206
 
207
+ # Complex is the one Numeric that is not a number a scalar can name, and
208
+ # #real? is the predicate for it — no class list to keep up to date.
209
+ def real(value, scalar)
210
+ return value if value.real?
211
+
212
+ refuse(value, scalar)
213
+ end
214
+
197
215
  def whole(value, scalar = "Int")
198
- # Integer(2.5) is 2 — a silent loss where refusing costs nothing
216
+ # Integer(2.5) is 2 — a silent loss where refusing costs nothing. `% 1`
217
+ # is exact for a BigDecimal and a Rational; asking Float would not be.
199
218
  return value.to_i if value.finite? && (value % 1).zero?
200
219
 
201
220
  raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
@@ -54,12 +54,15 @@ module GraphWeaver
54
54
  # block runs once, at the declaration (GraphBuilder.build reads the
55
55
  # registry there), and every read after replays the module it made.
56
56
  def replay(registry, registration)
57
- call, args, kwargs, block = registration
57
+ call, args, kwargs, block, aliases = registration
58
58
  entry = registry.public_send(call, *args, **kwargs, &block)
59
+ # the block's alias_field lines ran with it, so replay them too
60
+ entry[:aliases].merge!(aliases) if aliases
59
61
  return unless block && call == :extend_type
60
62
 
61
63
  registration[1] = args + [entry[:mixins].last]
62
64
  registration[3] = nil
65
+ registration[4] = entry[:aliases].dup
63
66
  end
64
67
  private :replay
65
68
 
@@ -68,7 +68,10 @@ module GraphWeaver
68
68
  # you generated) rather than a bad value, and T::Enum's own KeyError says
69
69
  # neither that nor which values exist. Raised bare so the enclosing
70
70
  # Hints.field brands it with the field.
71
- def self.enum(type, value)
71
+ # aliases (register_enum alias:) is wire spelling => the value it reads as.
72
+ def self.enum(type, value, aliases = nil)
73
+ value = aliases.fetch(value, value) if aliases
74
+
72
75
  type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
73
76
  end
74
77
 
@@ -21,18 +21,21 @@ module GraphWeaver
21
21
 
22
22
  # serializer/coercer are code-as-data from the generated file; nil
23
23
  # means identity (the wire value passes through untouched). coordinate
24
- # is the schema's name for the slot ("PetFilter.species"), so a refusal
25
- # can say where it happened without reflecting at runtime.
26
- Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate)
24
+ # is the schema's name for the slot ("PetFilter.species") and type its
25
+ # spelling of what goes there ("[Float!]!"), so a refusal can say where
26
+ # it happened, and in whose vocabulary, without reflecting at runtime.
27
+ Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
27
28
 
28
29
  # An enum reaching the library as input — an execute kwarg or an input
29
30
  # field — as the member or its wire value. Generated code calls these
30
31
  # rather than T::Enum.deserialize / the wire table directly: both raise a
31
32
  # bare KeyError naming an anonymous module and none of the values they
32
33
  # would have taken.
33
- def self.enum(type, value)
34
+ def self.enum(type, value, aliases = nil)
34
35
  return value if value.is_a?(type)
35
36
 
37
+ value = aliases.fetch(value, value) if aliases
38
+
36
39
  type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
37
40
  end
38
41
 
@@ -260,11 +263,14 @@ module GraphWeaver
260
263
  # out blaming the list that held the struct, in sorbet's words.
261
264
  next if T::Utils.coerce(info[:type_object]).recursively_valid?(value)
262
265
 
263
- type = T::Utils.coerce(info[:type]).to_s
266
+ # the SCHEMA's spelling, from the FIELDS row — #details[:type] is
267
+ # what an app translates for a user, and "T::Array[Float]" is the
268
+ # library's vocabulary leaking into theirs
269
+ field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
270
+ type = field&.type || T::Utils.coerce(info[:type]).to_s
264
271
  return GraphWeaver::InputError.new(
265
272
  "#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
266
- kind: :type_mismatch, path: [prop.to_s],
267
- coordinate: T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }&.coordinate,
273
+ kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
268
274
  value: GraphWeaver::Internal::Redact.value(prop, value),
269
275
  details: { type: }, struct: self,
270
276
  )
@@ -51,8 +51,11 @@ class GraphWeaver::Internal::Values
51
51
  UNREGISTERED = "T.untyped"
52
52
 
53
53
  # What JSON can hold. Anything else a pin offers is a Ruby object the
54
- # registration has to serialize before it can stand in for a response.
55
- WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
54
+ # registration has to serialize before it can stand in for a response
55
+ # Integer and Float, not Numeric, because a BigDecimal is a Numeric that
56
+ # JSON has no spelling for, and taking one as written sent 12.5 where the
57
+ # registration says the server writes "12.5".
58
+ WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
56
59
 
57
60
  # The fallback, for a scalar nobody registered: its prop is T.untyped, so
58
61
  # anything holds and a plausible shape beats a placeholder.
@@ -299,6 +299,73 @@ module GraphWeaver
299
299
  end
300
300
  end
301
301
 
302
+ # One query, checked against one schema. Both doors onto it —
303
+ # GraphWeaver.check_queries (every file on disk) and Client#check_query
304
+ # (a string) — report the same hashes and brand subgraphs the same way,
305
+ # because the implementation lives here rather than once each.
306
+ module QueryCheck
307
+ class << self
308
+ # A query's schema-validation errors as JSON-ready hashes, with the
309
+ # source position graphql-ruby reports. Unparseable counts as an error
310
+ # too — it doesn't validate either, and inline_fragments (which parses
311
+ # first) has already branded it with its position.
312
+ def errors(schema, source, shared, table = nil)
313
+ # path omitted: check_queries keys its report by file, so branding the
314
+ # message with it too would just print the path twice
315
+ schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
316
+ detail = error.to_h
317
+ location = detail["locations"]&.first || {}
318
+ subgraphs = table ? attribute(table, detail["extensions"]) : []
319
+ entry = {
320
+ "message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
321
+ "line" => location["line"],
322
+ "column" => location["column"],
323
+ }
324
+ subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
325
+ end
326
+ rescue GraphWeaver::QueryValidationError => e
327
+ # an unparseable query: codegen folds the position (and the file) into
328
+ # the message, and this report keeps them separate — same splitter the
329
+ # rendered error uses, so the two can't drift apart
330
+ e.errors.map do |detail|
331
+ _path, _position, message = GraphWeaver::QueryValidationError.split(detail)
332
+ detail.transform_keys(&:to_s).merge("message" => message)
333
+ end
334
+ end
335
+
336
+ # The routing table behind a dump path, when the dump is a composed
337
+ # supergraph: it says who resolves what, so a validation error can name
338
+ # the subgraph whose code to look at. nil for anything else — a plain
339
+ # schema, a url client, a live class are all unaffected.
340
+ def routing_table_for(path)
341
+ path = path.to_path if path.respond_to?(:to_path)
342
+ return unless path&.end_with?(".graphql", ".gql")
343
+
344
+ sdl = File.read(path)
345
+ SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
346
+ end
347
+
348
+ private
349
+
350
+ # Which subgraphs a validation error is about, on a federated schema:
351
+ # "Field 'weight' doesn't exist on type 'Product'" is much less useful
352
+ # than the same line plus "(products)" — whose code to look at, whose
353
+ # team to talk to. graphql-ruby reports the coordinate structurally, so
354
+ # this is a lookup rather than message parsing. Both halves of the
355
+ # coordinate are required: an argument error reports typeName "Field"
356
+ # (the AST node kind, not a type), and looking that up would attribute
357
+ # confidently and wrongly.
358
+ def attribute(table, extensions)
359
+ return [] unless extensions
360
+
361
+ type_name, field_name = extensions.values_at("typeName", "fieldName")
362
+ return [] unless type_name && field_name
363
+
364
+ table.responsible(type_name, field_name)
365
+ end
366
+ end
367
+ end
368
+
302
369
  # What makes two GraphQL requests the same request — and how one reads
303
370
  # when an error has to quote it. A cassette matches on this, so the
304
371
  # rules belong somewhere both the cassette and the error that reports a