graph_weaver 0.6.1 → 0.7.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -111,6 +111,15 @@ class GraphWeaver::Codegen
111
111
  }.uniq
112
112
  end
113
113
 
114
+ # `module A::B` does not define A, so a namespaced graph's file opens each
115
+ # outer segment first. A line apiece rather than nesting the whole body:
116
+ # the generated Ruby is then identical but for these, so adding a
117
+ # namespace: later diffs as additions.
118
+ def emit_namespace(out)
119
+ parts = @name.split("::")
120
+ (1...parts.size).each { |i| out << "module #{parts.first(i).join("::")}; end" }
121
+ end
122
+
114
123
  def emit_shared_aliases(out, names, namespace)
115
124
  return if names.empty?
116
125
 
@@ -148,6 +157,7 @@ class GraphWeaver::Codegen
148
157
  requires.each { |req| out << "require #{req.inspect}" }
149
158
  out << ""
150
159
  end
160
+ emit_namespace(out)
151
161
  out << "module #{@name}; end"
152
162
  out << ""
153
163
  if inputs.any?
@@ -184,6 +194,7 @@ class GraphWeaver::Codegen
184
194
  out << ""
185
195
  out << "# Generated by GraphWeaver #{GraphWeaver::VERSION} — do not edit."
186
196
  out << ""
197
+ emit_namespace(out)
187
198
  out << "module #{@name}"
188
199
  yield(out)
189
200
  out << "end"
@@ -225,6 +236,7 @@ class GraphWeaver::Codegen
225
236
  out << "require_relative \"types\""
226
237
  out << ""
227
238
  end
239
+ emit_namespace(out)
228
240
  out << "module #{@name}"
229
241
  out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
230
242
  # a GraphQL block string could contain a bare GRAPHQL line, which
@@ -233,7 +245,13 @@ class GraphWeaver::Codegen
233
245
  delimiter = "GRAPHQL"
234
246
  delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
235
247
  out << " QUERY = T.let(<<~'#{delimiter}', String)"
236
- @query.each_line { |line| out << " #{line}".rstrip }
248
+ # only the line's own newline comes off: trailing whitespace is part of a
249
+ # block-string argument's value, and <<~ takes the indent back off again.
250
+ # An empty line has no content to indent, so it stays empty.
251
+ @query.each_line do |line|
252
+ content = line.chomp
253
+ out << (content.empty? ? "" : " #{content}")
254
+ end
237
255
  out << " #{delimiter}"
238
256
  out << ""
239
257
  out << " # sent as the request's operationName — what an APM keys traces on"
@@ -345,6 +363,7 @@ class GraphWeaver::Codegen
345
363
  out << "#{pad}class #{node.class_name} < T::Struct"
346
364
  out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
347
365
  out << "#{pad} include GraphWeaver::Hints"
366
+ out << "#{pad} include GraphWeaver::ResultStruct"
348
367
  node.mixins.each do |mixin|
349
368
  out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
350
369
  end
@@ -362,6 +381,8 @@ class GraphWeaver::Codegen
362
381
  end
363
382
 
364
383
  node.fields.each do |field|
384
+ note = renamed_note(field.prop, field.key)
385
+ out << "#{pad} #{note}" if note
365
386
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
366
387
  end
367
388
 
@@ -376,10 +397,23 @@ class GraphWeaver::Codegen
376
397
  out << "#{pad} rescue GraphWeaver::Error"
377
398
  out << "#{pad} raise # already branded by a nested struct or leaf — keep the innermost context"
378
399
  out << "#{pad} rescue StandardError => e" # sorbet's prop check, mostly
379
- out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, " \
400
+ out << "#{pad} raise GraphWeaver::CastError.new(struct: self, " \
380
401
  "message: GraphWeaver::Hints.cast_message(self, data, e))"
381
402
  out << "#{pad} end"
382
403
 
404
+ # the mirror of from_h: the response keys, each leaf back through its
405
+ # scalar's serialize:, so from_h reads what as_json wrote. Rails calls
406
+ # it for `render json:`, and ResultStruct#to_json goes through it.
407
+ out << ""
408
+ out << "#{pad} sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }"
409
+ out << "#{pad} def as_json(*_options)"
410
+ out << "#{pad} {"
411
+ node.fields.each do |field|
412
+ out << "#{pad} #{field.key.inspect} => #{field_json(field)},"
413
+ end
414
+ out << "#{pad} }"
415
+ out << "#{pad} end"
416
+
383
417
  # alias delegators (extend_type alias:) — typed accessors that project a
384
418
  # selected field onto the struct, next to the honest wire data
385
419
  node.aliases.each do |a|
@@ -396,7 +430,7 @@ class GraphWeaver::Codegen
396
430
  out << "#{pad}module #{node.class_name}"
397
431
  out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
398
432
 
399
- structs = node.members.values + [node.catch_all].compact
433
+ structs = node.members.values + [node.catch_all]
400
434
  structs.each do |member|
401
435
  emit_object(member, out, indent + 1)
402
436
  out << ""
@@ -408,21 +442,14 @@ class GraphWeaver::Codegen
408
442
  out << ""
409
443
  out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
410
444
  out << "#{pad} def self.from_h(data)"
411
- if node.catch_all
412
- out << "#{pad} case data.fetch(\"__typename\")"
413
- else
414
- out << "#{pad} case (typename = data.fetch(\"__typename\"))"
415
- end
445
+ out << "#{pad} case data.fetch(\"__typename\")"
416
446
  node.members.each do |graphql_name, member|
417
447
  out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
418
448
  end
419
- if node.catch_all
420
- out << "#{pad} # a member this query names no fields on — including one the"
421
- out << "#{pad} # schema grew since generation"
422
- out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
423
- else
424
- out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
425
- end
449
+ # every dispatch has a catch-all, so no __typename is unexpected
450
+ out << "#{pad} # a member this query names no fields on — including one the"
451
+ out << "#{pad} # schema grew since generation"
452
+ out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
426
453
  out << "#{pad} end"
427
454
  out << "#{pad} end"
428
455
  out << "#{pad}end"
@@ -432,6 +459,13 @@ class GraphWeaver::Codegen
432
459
  # client/client= carry no per-query types, so they live in the gem
433
460
  out << " # client / client= — see GraphWeaver::QueryModule"
434
461
  out << " extend GraphWeaver::QueryModule"
462
+ if @graph_name
463
+ out << ""
464
+ out << " # the graph this module was generated from — what a test mode builds"
465
+ out << " # its stand-in client from"
466
+ out << " GRAPH = T.let(#{@graph_name.inspect}, Symbol)"
467
+ out << " private_constant :GRAPH"
468
+ end
435
469
  if @client_const
436
470
  out << ""
437
471
  out << " # the baked default client, resolved on first use"
@@ -474,12 +508,15 @@ class GraphWeaver::Codegen
474
508
  flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
475
509
  } + ["client: nil"]
476
510
 
477
- call = "client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME)"
511
+ # QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module,
512
+ # so the gem gets to bracket every request without a line of it landing
513
+ # in every generated file
514
+ call = "dispatch(variables, client:)"
478
515
 
479
516
  # execute returns the full envelope; execute! is the strict shortcut for
480
517
  # the typed result, or a raised QueryError.
481
- out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion below"
482
- out << " # instead of sorbet-runtime's argument check; srb tc still holds typed call sites."
518
+ out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion"
519
+ out << " # below instead of being rejected by sorbet-runtime's argument check."
483
520
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]).checked(:never) }"
484
521
  out << " def self.execute(#{kwargs.join(", ")})"
485
522
  emit_variables(out, required, optional, omitted)
@@ -558,16 +595,16 @@ class GraphWeaver::Codegen
558
595
 
559
596
  # A kwarg's trip onto the wire: normalize whatever arrived into the type
560
597
  # the sig promises — the sig itself is `.checked(:never)`, so this is the
561
- # check — then serialize. Coercion is wrapped so a refusal names the
562
- # variable and the operation; the value alone locates nothing.
598
+ # check — then serialize. The whole trip runs inside Coerce.variable, so a
599
+ # refusal from either half names the variable and the operation; the value
600
+ # alone locates nothing. (A serializer raising used to escape bare: a cast
601
+ # that answers nil hands `nil.upcase` to whatever called execute.)
563
602
  def variable_serialize(var)
564
- value = if var.node.coerce?
565
- "GraphWeaver::Coerce.variable(#{var.wire.inspect}, OPERATION_NAME, #{var.kwarg}) " \
566
- "{ |v| #{var.node.coerce("v")} }"
567
- else
568
- var.kwarg
569
- end
570
- var.node.serialize_identity? ? value : var.node.serialize(value, 1)
603
+ return var.kwarg if !var.node.coerce? && var.node.serialize_identity?
604
+
605
+ value = var.node.coerce? ? var.node.coerce("v") : "v"
606
+ value = var.node.serialize(value, 1) unless var.node.serialize_identity?
607
+ "GraphWeaver::Coerce.variable(#{var.wire.inspect}, OPERATION_NAME, #{var.kwarg}) { |v| #{value} }"
571
608
  end
572
609
 
573
610
  def field_cast(field)
@@ -588,6 +625,30 @@ class GraphWeaver::Codegen
588
625
  "GraphWeaver::Hints.field(self, #{field.key.inspect}) { #{cast} }"
589
626
  end
590
627
 
628
+ # One prop written back the way the server spelled it. The inverse of
629
+ # field_cast, minus its branding: rendering a value the library already
630
+ # cast can't fail on the value, only on a registration whose serialize:
631
+ # raises — which is the app's own code and says so.
632
+ def field_json(field)
633
+ node = field.node
634
+ # a prop is only ever read off a receiver, which is what lets `next` and
635
+ # `end` be props at all (see RUBY_KEYWORDS) — so here the receiver is us
636
+ prop = RUBY_KEYWORDS.include?(field.prop) ? "self.#{field.prop}" : field.prop
637
+ return prop if node.serialize_identity?
638
+ return node.serialize(prop, 1) if node.non_null?
639
+
640
+ "#{prop}&.then { |v1| #{node.serialize("v1", 2)} }"
641
+ end
642
+
643
+ # Why a prop is spelled unlike its wire name, for the one case a reader
644
+ # can't infer: a reserved name took a trailing underscore. camelCase →
645
+ # snake_case is the rule the whole file follows, and the wire key is
646
+ # already spelled out beside it (from_h's `data[...]`, a FIELDS row), so
647
+ # noting every prop would be noise.
648
+ def renamed_note(prop, wire)
649
+ "# wire: #{wire} — reserved as a prop name" if prop == "#{underscore(wire)}_"
650
+ end
651
+
591
652
  # A module-level T::Struct per input type: typed consts plus a FIELDS
592
653
  # table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
593
654
  # coerce live once in the gem, not unrolled per struct (bool_exp
@@ -599,6 +660,11 @@ class GraphWeaver::Codegen
599
660
  out << "#{pad} include GraphWeaver::InputStruct"
600
661
  out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
601
662
  out << ""
663
+ # the schema's name for this type, so a refusal reports GraphQL
664
+ # vocabulary rather than the Ruby class generation happened to pick
665
+ out << "#{pad} GRAPHQL_NAME = T.let(#{node.graphql_name.inspect}, String)"
666
+ out << "#{pad} private_constant :GRAPHQL_NAME"
667
+ out << ""
602
668
  if node.one_of
603
669
  out << "#{pad} # @oneOf: every field is nullable, so exactly-one is checked at runtime"
604
670
  out << "#{pad} ONE_OF = T.let(true, T::Boolean)"
@@ -612,15 +678,18 @@ class GraphWeaver::Codegen
612
678
  type = field.node.prop_type
613
679
  type = "T.nilable(#{type})" if !field.required && field.node.non_null? && type != "T.untyped"
614
680
  default = field.required ? "" : ", default: nil"
681
+ note = renamed_note(field.prop, field.wire)
682
+ out << "#{pad} #{note}" if note
615
683
  out << "#{pad} const :#{field.prop}, #{type}#{default}"
616
684
  end
617
685
  out << ""
618
- out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
686
+ out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate) per field"
619
687
  out << "#{pad} FIELDS = T.let(["
620
688
  node.fields.each do |field|
621
689
  serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
622
690
  coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
623
- out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}),"
691
+ coordinate = "#{node.graphql_name}.#{field.wire}"
692
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}),"
624
693
  end
625
694
  out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
626
695
  # InputStruct reads it with const_get, which privacy doesn't block
@@ -78,7 +78,8 @@ class GraphWeaver::Codegen
78
78
  end
79
79
  end
80
80
 
81
- class << self
81
+ # The enum half of one graph's registrations — see Codegen::Registry.
82
+ class Registry
82
83
  # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
83
84
  # implementation — GraphWeaver.register_enum is a delegate, so the same
84
85
  # call reaches it whichever door you came in by.
@@ -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