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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- 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
|
-
|
|
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::
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
482
|
-
out << " # instead of sorbet-runtime's argument check
|
|
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.
|
|
562
|
-
# variable and the operation; the value
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|