graph_weaver 0.7.3 → 0.7.5
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.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +176 -22
- data/docs/getting_started.md +174 -14
- data/docs/i18n.md +4 -4
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +161 -35
- data/docs/testing.md +24 -3
- data/docs/upgrading.md +51 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +35 -16
- data/lib/graph_weaver/codegen/enum_type.rb +149 -19
- data/lib/graph_weaver/codegen/nodes.rb +72 -37
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +259 -106
- data/lib/graph_weaver/coerce.rb +25 -6
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +23 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +31 -10
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +12 -4
- data/lib/graph_weaver/internal.rb +84 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +101 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +80 -74
- metadata +3 -2
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
class GraphWeaver::Codegen
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# What a register_enum said about one GraphQL enum: the app-owned T::Enum
|
|
6
|
+
# its values map onto, and which wire spellings are the same value.
|
|
7
7
|
#
|
|
8
8
|
# class PetKind < T::Enum
|
|
9
9
|
# enums { Cat = new("cat"); Dog = new("dog") }
|
|
@@ -17,11 +17,27 @@ class GraphWeaver::Codegen
|
|
|
17
17
|
# value the schema declares must resolve — generation fails naming the
|
|
18
18
|
# gaps — unless fallback: names a member to absorb unknown values
|
|
19
19
|
# (forward-compat for servers that add members; inputs stay strict).
|
|
20
|
+
#
|
|
21
|
+
# alias: { "legacy_mode" => "LEGACY_MODE" } says two wire values are one
|
|
22
|
+
# value — both spellings cast, the target is what serializes. It is the
|
|
23
|
+
# whole registration when there is no T::Enum to map onto, and then the
|
|
24
|
+
# generated enum gets one constant for the target and none for the alias.
|
|
25
|
+
#
|
|
26
|
+
# fallback: true is the same type-less form asking for leniency instead:
|
|
27
|
+
# the generated enum gains an Other member and absorbs undeclared wire
|
|
28
|
+
# values into it (see Codegen#enum_values).
|
|
20
29
|
class EnumType
|
|
21
30
|
attr_reader :graphql_name, :type, :fallback, :requires
|
|
22
31
|
|
|
23
|
-
def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
32
|
+
def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil, aliases: nil)
|
|
24
33
|
@graphql_name = graphql_name.to_s
|
|
34
|
+
@aliases = normalize_aliases!(aliases)
|
|
35
|
+
@type = type
|
|
36
|
+
@map = map || {}
|
|
37
|
+
@fallback = fallback
|
|
38
|
+
|
|
39
|
+
return alias_only!(map, fallback, requires) if type.nil?
|
|
40
|
+
|
|
25
41
|
# A name, not the class, is what you write when the constant won't
|
|
26
42
|
# resolve yet — which in Rails means a config/initializers file, since
|
|
27
43
|
# autoloading is set up after those run. Say where it does resolve.
|
|
@@ -36,9 +52,6 @@ class GraphWeaver::Codegen
|
|
|
36
52
|
raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
|
|
37
53
|
end
|
|
38
54
|
|
|
39
|
-
@type = type
|
|
40
|
-
@map = map || {}
|
|
41
|
-
@fallback = fallback
|
|
42
55
|
@requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
|
|
43
56
|
|
|
44
57
|
if fallback && !type.values.include?(fallback)
|
|
@@ -46,28 +59,140 @@ class GraphWeaver::Codegen
|
|
|
46
59
|
end
|
|
47
60
|
end
|
|
48
61
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
def
|
|
52
|
-
|
|
62
|
+
# register_enum("Species", fallback: true): the generated enum gains an
|
|
63
|
+
# Other member and casts every undeclared wire value to it.
|
|
64
|
+
def generated_fallback? = type.nil? && fallback == true
|
|
65
|
+
|
|
66
|
+
# The wire tables for a mapped enum: [wire value => member, member => the
|
|
67
|
+
# wire value that goes out]. Every spelling casts; an alias's target is the
|
|
68
|
+
# one that serializes.
|
|
69
|
+
def tables_for(wire_values)
|
|
70
|
+
aliases = aliases_for(wire_values)
|
|
71
|
+
from_wire = {}
|
|
53
72
|
missing = []
|
|
54
73
|
|
|
55
74
|
wire_values.each do |wire|
|
|
56
|
-
|
|
57
|
-
member
|
|
75
|
+
canonical = aliases.fetch(wire, wire)
|
|
76
|
+
member = @map[canonical] || infer(canonical)
|
|
77
|
+
member ? from_wire[wire] = member : missing << canonical
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
if missing.any? && !fallback
|
|
61
81
|
raise GraphWeaver::Error,
|
|
62
|
-
"#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
|
|
82
|
+
"#{type} has no member for #{graphql_name} value(s) #{missing.uniq.join(", ")} — " \
|
|
63
83
|
"add them, pin with map:, or absorb with fallback:"
|
|
64
84
|
end
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
[from_wire, to_wire(from_wire, aliases)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# alias spelling => the value it is read as, checked against what the
|
|
90
|
+
# schema declares — an alias for or onto a value that isn't there is a
|
|
91
|
+
# registration this schema disproves.
|
|
92
|
+
def aliases_for(wire_values)
|
|
93
|
+
declared = wire_values.sort.join(", ")
|
|
94
|
+
|
|
95
|
+
@aliases.each do |from, to|
|
|
96
|
+
unless wire_values.include?(from)
|
|
97
|
+
raise GraphWeaver::Error,
|
|
98
|
+
"enum #{graphql_name} has no value #{from.inspect} to alias — its values are #{declared}; " \
|
|
99
|
+
"fix the spelling or drop the alias"
|
|
100
|
+
end
|
|
101
|
+
unless wire_values.include?(to)
|
|
102
|
+
raise GraphWeaver::Error,
|
|
103
|
+
"enum #{graphql_name}: alias #{from.inspect} => #{to.inspect} names no value of #{graphql_name} — " \
|
|
104
|
+
"its values are #{declared}; the target is the spelling that goes on the wire"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@aliases
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The register_enum a set of indistinguishable wire values needs, ready to
|
|
112
|
+
# paste. SCREAMING_CASE is the GraphQL convention, so the odd spelling is
|
|
113
|
+
# guessed as the alias — every caller's sentence says to check the direction.
|
|
114
|
+
def self.alias_suggestion(graphql_name, groups, type = nil)
|
|
115
|
+
pairs = groups.flat_map { |group|
|
|
116
|
+
target = group.find { |value| value == value.upcase } || group.first
|
|
117
|
+
(group - [target]).map { |value| "#{value.inspect} => #{target.inspect}" }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
"GraphWeaver.register_enum(#{graphql_name.inspect}#{type ? ", #{type}" : ""}, " \
|
|
121
|
+
"alias: { #{pairs.join(", ")} })"
|
|
67
122
|
end
|
|
68
123
|
|
|
69
124
|
private
|
|
70
125
|
|
|
126
|
+
# Without a T::Enum there is nothing for map:/requires: to describe, so
|
|
127
|
+
# alias: and fallback: true are the whole registration.
|
|
128
|
+
def alias_only!(map, fallback, requires)
|
|
129
|
+
if fallback && fallback != true
|
|
130
|
+
raise ArgumentError, "register_enum(#{graphql_name.inspect}, fallback: #{fallback.inspect}): the " \
|
|
131
|
+
"generated enum generates its fallback member too, so say fallback: true. To fall back onto a " \
|
|
132
|
+
"member of your own, pass the T::Enum: " \
|
|
133
|
+
"register_enum(#{graphql_name.inspect}, YourEnum, fallback: YourEnum::Unknown)"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if @aliases.empty? && !fallback
|
|
137
|
+
raise ArgumentError, "register_enum(#{graphql_name.inspect}) says nothing about #{graphql_name} — " \
|
|
138
|
+
"pass the T::Enum to map it onto, alias: { \"old\" => \"NEW\" } to read two wire values as one, " \
|
|
139
|
+
"or fallback: true to absorb values the server adds"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
extra = { map:, requires: }.compact.keys.first
|
|
143
|
+
if extra
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"register_enum(#{graphql_name.inspect}) takes no #{extra}: — that describes a T::Enum of your own, " \
|
|
146
|
+
"so pass one: register_enum(#{graphql_name.inspect}, YourEnum, #{extra}: {...})"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@requires = []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Sorted so generated source is stable across registration order.
|
|
153
|
+
def normalize_aliases!(aliases)
|
|
154
|
+
return {} if aliases.nil?
|
|
155
|
+
|
|
156
|
+
unless aliases.is_a?(Hash)
|
|
157
|
+
raise ArgumentError, "alias: is a hash of wire value => wire value, got #{aliases.inspect}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
pairs = aliases.to_h { |from, to| [from.to_s, to.to_s] }
|
|
161
|
+
|
|
162
|
+
self_alias = pairs.find { |from, to| from == to }
|
|
163
|
+
if self_alias
|
|
164
|
+
raise ArgumentError, "register_enum(#{graphql_name.inspect}): alias #{self_alias.first.inspect} => " \
|
|
165
|
+
"#{self_alias.last.inspect} reads a value as itself — drop it"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
chained = pairs.keys.find { |from| pairs.value?(from) }
|
|
169
|
+
if chained
|
|
170
|
+
raise ArgumentError, "register_enum(#{graphql_name.inspect}): #{chained.inspect} is both an alias and " \
|
|
171
|
+
"the value an alias points at — an alias can't chain; point every spelling at the one that goes on the wire"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
pairs.sort.to_h
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# member => the one wire value it serializes to. Two spellings on one
|
|
178
|
+
# member with no alias saying which goes out is ambiguous — invert used to
|
|
179
|
+
# pick whichever came last, which for a deprecation pair was the dead one.
|
|
180
|
+
def to_wire(from_wire, aliases)
|
|
181
|
+
canonical = from_wire.except(*aliases.keys)
|
|
182
|
+
ambiguous = canonical.group_by { |_, member| member }.select { |_, pairs| pairs.size > 1 }
|
|
183
|
+
if ambiguous.any?
|
|
184
|
+
member, = ambiguous.first
|
|
185
|
+
groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
|
|
186
|
+
more = ambiguous.size - 1
|
|
187
|
+
raise GraphWeaver::Error,
|
|
188
|
+
"enum #{graphql_name}: #{groups.first.join(" and ")} both map onto the #{type} member " \
|
|
189
|
+
"#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
|
|
190
|
+
"goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
canonical.invert
|
|
194
|
+
end
|
|
195
|
+
|
|
71
196
|
# "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
|
|
72
197
|
def infer(wire)
|
|
73
198
|
@type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
|
|
@@ -80,19 +205,24 @@ class GraphWeaver::Codegen
|
|
|
80
205
|
|
|
81
206
|
# The enum half of one graph's registrations — see Codegen::Registry.
|
|
82
207
|
class Registry
|
|
83
|
-
# Map a GraphQL enum onto an app-owned T::Enum
|
|
84
|
-
#
|
|
85
|
-
#
|
|
208
|
+
# Map a GraphQL enum onto an app-owned T::Enum, fold two of its wire
|
|
209
|
+
# spellings into one value, or absorb the ones the server hasn't told you
|
|
210
|
+
# about yet (see EnumType). The one implementation —
|
|
211
|
+
# GraphWeaver.register_enum is a delegate, so the same call reaches it
|
|
212
|
+
# whichever door you came in by.
|
|
86
213
|
#
|
|
87
214
|
# A value map is a natural third *positional* guess, and Ruby's arity
|
|
88
215
|
# complaint ("given 3, expected 2") never mentions the keyword.
|
|
89
|
-
def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil
|
|
216
|
+
def register_enum(graphql_name, type = nil, positional_map = nil, map: nil, fallback: nil, requires: nil,
|
|
217
|
+
alias: nil)
|
|
90
218
|
if positional_map
|
|
91
219
|
raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
|
|
92
220
|
"register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
|
|
93
221
|
end
|
|
94
222
|
|
|
95
|
-
|
|
223
|
+
# `alias` is a Ruby keyword, so the parameter is only readable through binding
|
|
224
|
+
aliases = binding.local_variable_get(:alias)
|
|
225
|
+
enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:, aliases:)
|
|
96
226
|
end
|
|
97
227
|
|
|
98
228
|
def enum_registry
|
|
@@ -113,11 +113,11 @@ class GraphWeaver::Codegen
|
|
|
113
113
|
|
|
114
114
|
def cast(expr, depth)
|
|
115
115
|
var = "v#{depth}"
|
|
116
|
-
element =
|
|
117
|
-
@of.identity?
|
|
118
|
-
|
|
119
|
-
"#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
120
|
-
|
|
116
|
+
element =
|
|
117
|
+
if @of.identity? then var
|
|
118
|
+
elsif @of.non_null? then @of.cast(var, depth + 1)
|
|
119
|
+
else "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
120
|
+
end
|
|
121
121
|
|
|
122
122
|
"#{expr}.map { |#{var}| #{element} }"
|
|
123
123
|
end
|
|
@@ -127,11 +127,11 @@ class GraphWeaver::Codegen
|
|
|
127
127
|
|
|
128
128
|
def serialize(expr, depth)
|
|
129
129
|
var = "v#{depth}"
|
|
130
|
-
element =
|
|
131
|
-
@of.serialize_identity?
|
|
132
|
-
|
|
133
|
-
"#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
134
|
-
|
|
130
|
+
element =
|
|
131
|
+
if @of.serialize_identity? then var
|
|
132
|
+
elsif @of.non_null? then @of.serialize(var, depth + 1)
|
|
133
|
+
else "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
134
|
+
end
|
|
135
135
|
|
|
136
136
|
"#{expr}.map { |#{var}| #{element} }"
|
|
137
137
|
end
|
|
@@ -153,11 +153,11 @@ class GraphWeaver::Codegen
|
|
|
153
153
|
|
|
154
154
|
def hash_coerce(expr, depth)
|
|
155
155
|
var = "v#{depth}"
|
|
156
|
-
inner =
|
|
157
|
-
@of.hash_coerce_identity?
|
|
158
|
-
|
|
159
|
-
"#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
|
|
160
|
-
|
|
156
|
+
inner =
|
|
157
|
+
if @of.hash_coerce_identity? then var
|
|
158
|
+
elsif @of.non_null? then @of.hash_coerce(var, depth + 1)
|
|
159
|
+
else "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
|
|
160
|
+
end
|
|
161
161
|
return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
|
|
162
162
|
|
|
163
163
|
# the index is a path segment — `where._and.0._not.species` needs the 0
|
|
@@ -178,14 +178,16 @@ class GraphWeaver::Codegen
|
|
|
178
178
|
|
|
179
179
|
attr_reader :class_name, :fields
|
|
180
180
|
# the GraphQL type this struct was generated from, any registered helper
|
|
181
|
-
# modules to include,
|
|
182
|
-
|
|
181
|
+
# modules to include, any resolved alias delegators, and the member names a
|
|
182
|
+
# mixin declares abstract — Sorbet demands the override on those (extend_type)
|
|
183
|
+
attr_accessor :graphql_type, :mixins, :aliases, :overrides
|
|
183
184
|
|
|
184
185
|
def initialize(class_name)
|
|
185
186
|
@class_name = class_name
|
|
186
187
|
@fields = []
|
|
187
188
|
@mixins = []
|
|
188
189
|
@aliases = []
|
|
190
|
+
@overrides = []
|
|
189
191
|
end
|
|
190
192
|
|
|
191
193
|
def bare_type = class_name
|
|
@@ -203,19 +205,37 @@ class GraphWeaver::Codegen
|
|
|
203
205
|
end
|
|
204
206
|
|
|
205
207
|
class EnumNode < Node
|
|
206
|
-
attr_reader :class_name, :values
|
|
208
|
+
attr_reader :class_name, :values, :aliases
|
|
207
209
|
|
|
208
|
-
|
|
210
|
+
# fallback: the name of the member unknown wire values cast to (register_enum
|
|
211
|
+
# fallback: true), or nil for a strict enum
|
|
212
|
+
def initialize(class_name, values, aliases = {}, fallback: nil)
|
|
209
213
|
@class_name = class_name
|
|
210
214
|
@values = values
|
|
215
|
+
@aliases = aliases
|
|
216
|
+
@fallback = fallback
|
|
211
217
|
end
|
|
212
218
|
|
|
213
219
|
def bare_type = class_name
|
|
214
220
|
|
|
221
|
+
# wire spellings this enum reads as another of its values (register_enum
|
|
222
|
+
# alias:) — a module-level table, emitted beside the class
|
|
223
|
+
def aliased? = !@aliases.empty?
|
|
224
|
+
def alias_const = "#{GraphWeaver::Inflect.underscore(class_name).upcase}_ALIASES"
|
|
225
|
+
|
|
226
|
+
# register_enum fallback: true — the extra member every value the schema
|
|
227
|
+
# doesn't declare casts to, and the one member a variable can't send
|
|
228
|
+
attr_reader :fallback
|
|
229
|
+
def fallback? = !@fallback.nil?
|
|
230
|
+
def fallback_const = "#{class_name}::#{@fallback}"
|
|
231
|
+
|
|
215
232
|
def cast(expr, _depth)
|
|
216
|
-
"GraphWeaver::Hints.enum(#{class_name}, #{expr})"
|
|
233
|
+
"GraphWeaver::Hints.enum(#{class_name}, #{expr}#{runtime_args})"
|
|
217
234
|
end
|
|
218
235
|
|
|
236
|
+
# Other serializes to ENUM_FALLBACK_WIRE, which casts back to Other — so a
|
|
237
|
+
# result carrying it still round-trips through #as_json, even though no
|
|
238
|
+
# server would accept that spelling.
|
|
219
239
|
def serialize(expr, _depth)
|
|
220
240
|
"#{expr}.serialize"
|
|
221
241
|
end
|
|
@@ -227,7 +247,11 @@ class GraphWeaver::Codegen
|
|
|
227
247
|
def coerce? = true
|
|
228
248
|
|
|
229
249
|
def coerce(expr)
|
|
230
|
-
"GraphWeaver::InputStruct.enum(#{class_name}, #{expr})"
|
|
250
|
+
"GraphWeaver::InputStruct.enum(#{class_name}, #{expr}#{runtime_args})"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def runtime_args
|
|
254
|
+
"#{", #{alias_const}" if aliased?}#{", fallback: #{fallback_const}" if fallback?}"
|
|
231
255
|
end
|
|
232
256
|
|
|
233
257
|
def input_type = "T.any(#{class_name}, String)"
|
|
@@ -240,13 +264,13 @@ class GraphWeaver::Codegen
|
|
|
240
264
|
# <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
|
|
241
265
|
# casting absorb unknown wire values (inputs stay strict).
|
|
242
266
|
class MappedEnum < Node
|
|
243
|
-
attr_reader :graphql_name, :mapping
|
|
267
|
+
attr_reader :graphql_name, :mapping, :to_wire
|
|
244
268
|
|
|
245
269
|
def initialize(enum_type, wire_values)
|
|
246
270
|
@graphql_name = enum_type.graphql_name
|
|
247
271
|
@type_name = enum_type.type.name
|
|
248
272
|
@fallback = enum_type.fallback
|
|
249
|
-
@mapping = enum_type.
|
|
273
|
+
@mapping, @to_wire = enum_type.tables_for(wire_values)
|
|
250
274
|
end
|
|
251
275
|
|
|
252
276
|
def const_prefix = GraphWeaver::Inflect.underscore(@graphql_name).upcase
|
|
@@ -350,20 +374,22 @@ class GraphWeaver::Codegen
|
|
|
350
374
|
def nested = self
|
|
351
375
|
end
|
|
352
376
|
|
|
353
|
-
# A reference to a
|
|
354
|
-
#
|
|
355
|
-
# <Name
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
377
|
+
# A reference to a type hoisted into the shared types module (a named shared
|
|
378
|
+
# fragment spread as a whole field): the query casts through <Name>.from_h,
|
|
379
|
+
# where <Name> is the alias the query module gives GraphQLTypes::<Name>. The
|
|
380
|
+
# type lives once in the shared module, so the same fragment across queries
|
|
381
|
+
# is one Ruby type — nested is nil, nothing is emitted here.
|
|
382
|
+
class HoistedRefNode < Node
|
|
383
|
+
# graphql_type is carried for refusals alone — an alias path that tries to
|
|
384
|
+
# read into the hoisted struct says which type to register itself on
|
|
385
|
+
attr_reader :class_name, :graphql_type
|
|
386
|
+
|
|
387
|
+
def initialize(class_name, graphql_type = nil)
|
|
363
388
|
@class_name = class_name
|
|
389
|
+
@graphql_type = graphql_type
|
|
364
390
|
end
|
|
365
391
|
|
|
366
|
-
def bare_type =
|
|
392
|
+
def bare_type = class_name
|
|
367
393
|
|
|
368
394
|
def cast(expr, _depth)
|
|
369
395
|
"#{class_name}.from_h(#{expr})"
|
|
@@ -374,12 +400,21 @@ class GraphWeaver::Codegen
|
|
|
374
400
|
end
|
|
375
401
|
end
|
|
376
402
|
|
|
403
|
+
# The same, on an abstract type: the hoisted name is a dispatch module, so
|
|
404
|
+
# the Ruby type is its member union rather than the module itself.
|
|
405
|
+
class UnionRefNode < HoistedRefNode
|
|
406
|
+
def bare_type = "#{class_name}::Type"
|
|
407
|
+
end
|
|
408
|
+
|
|
377
409
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
378
410
|
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
379
411
|
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
|
380
412
|
# normalized (and type-checked) through the generated .coerce.
|
|
381
413
|
class InputNode < Node
|
|
382
|
-
|
|
414
|
+
# type: the schema's spelling of the field's type ("[Float!]!"), which a
|
|
415
|
+
# refusal reports — the Sorbet prop type is the library's vocabulary, not
|
|
416
|
+
# the caller's.
|
|
417
|
+
Field = Struct.new(:prop, :wire, :node, :required, :type)
|
|
383
418
|
|
|
384
419
|
# graphql_name as well as class_name: a schema coordinate is spelled the
|
|
385
420
|
# schema's way (pokemon_bool_exp.name), which camelize has already lost.
|
|
@@ -443,6 +478,6 @@ class GraphWeaver::Codegen
|
|
|
443
478
|
# The IR is codegen's own vocabulary — every node type is reachable only
|
|
444
479
|
# from inside the walk.
|
|
445
480
|
private_constant :Node, :Scalar, :NonNull, :List, :ObjectNode, :EnumNode,
|
|
446
|
-
:MappedEnum, :NarrowedNode, :UnionNode, :
|
|
447
|
-
:RepresentationNode
|
|
481
|
+
:MappedEnum, :NarrowedNode, :UnionNode, :HoistedRefNode, :UnionRefNode,
|
|
482
|
+
:InputNode, :RepresentationNode
|
|
448
483
|
end
|
|
@@ -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
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
91
|
+
# Time.iso8601, not Time.parse: the latter also reads "Jan 15 2024
|
|
92
|
+
# 10:20", which no spec-compliant server writes, at 3× 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
|
|
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
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
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
|