graph_weaver 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -6,244 +6,145 @@ require "sorbet-runtime"
|
|
|
6
6
|
|
|
7
7
|
# Generates plain, statically-typecheckable Ruby from a GraphQL query +
|
|
8
8
|
# schema: nested T::Structs, from_h casting code, and a sig'd execute
|
|
9
|
-
# method.
|
|
10
|
-
#
|
|
11
|
-
# result type of each query.
|
|
9
|
+
# method. The output is source on disk, so srb tc sees the exact result
|
|
10
|
+
# type of each query.
|
|
12
11
|
#
|
|
13
12
|
# Supports queries and mutations; plain fields, inline fragments, named
|
|
14
13
|
# fragment spreads (including interface type conditions), union- and
|
|
15
14
|
# interface-typed fields (dispatch on __typename), enums (generated
|
|
16
|
-
# T::Enum), and typed variables (kwargs on execute).
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# deserialization for scalars whose wire format differs from their Ruby
|
|
30
|
-
# type; anything absent passes through untouched
|
|
31
|
-
SCALAR_CASTS = {
|
|
32
|
-
"Date" => ->(expr) { "Date.iso8601(#{expr})" },
|
|
33
|
-
}.freeze
|
|
34
|
-
|
|
35
|
-
# the inverse, for serializing variables onto the wire
|
|
36
|
-
SCALAR_SERIALIZERS = {
|
|
37
|
-
"Date" => ->(expr) { "#{expr}.iso8601" },
|
|
38
|
-
}.freeze
|
|
39
|
-
|
|
40
|
-
class Scalar
|
|
41
|
-
def initialize(name)
|
|
42
|
-
@name = name
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def bare_type
|
|
46
|
-
SCALARS.fetch(@name, "T.untyped")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def prop_type
|
|
50
|
-
"T.nilable(#{bare_type})"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def cast(expr, _depth)
|
|
54
|
-
SCALAR_CASTS.fetch(@name) { return expr }.call(expr)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def identity?
|
|
58
|
-
!SCALAR_CASTS.key?(@name)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def serialize(expr, _depth)
|
|
62
|
-
SCALAR_SERIALIZERS.fetch(@name) { return expr }.call(expr)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def serialize_identity?
|
|
66
|
-
!SCALAR_SERIALIZERS.key?(@name)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def non_null? = false
|
|
70
|
-
def nested = nil
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
class NonNull
|
|
74
|
-
def initialize(of)
|
|
75
|
-
@of = of
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def bare_type = @of.bare_type
|
|
79
|
-
def prop_type = bare_type
|
|
80
|
-
def cast(expr, depth) = @of.cast(expr, depth)
|
|
81
|
-
def identity? = @of.identity?
|
|
82
|
-
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
83
|
-
def serialize_identity? = @of.serialize_identity?
|
|
84
|
-
def non_null? = true
|
|
85
|
-
def nested = @of.nested
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
class List
|
|
89
|
-
def initialize(of)
|
|
90
|
-
@of = of
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def bare_type
|
|
94
|
-
"T::Array[#{@of.prop_type}]"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def prop_type
|
|
98
|
-
"T.nilable(#{bare_type})"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def cast(expr, depth)
|
|
102
|
-
var = "v#{depth}"
|
|
103
|
-
element = if @of.non_null? || @of.identity?
|
|
104
|
-
@of.identity? ? var : @of.cast(var, depth + 1)
|
|
105
|
-
else
|
|
106
|
-
"#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
"#{expr}.map { |#{var}| #{element} }"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def identity? = @of.identity?
|
|
113
|
-
|
|
114
|
-
def serialize(expr, depth)
|
|
115
|
-
var = "v#{depth}"
|
|
116
|
-
element = if @of.non_null? || @of.serialize_identity?
|
|
117
|
-
@of.serialize_identity? ? var : @of.serialize(var, depth + 1)
|
|
118
|
-
else
|
|
119
|
-
"#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
"#{expr}.map { |#{var}| #{element} }"
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def serialize_identity? = @of.serialize_identity?
|
|
126
|
-
def non_null? = false
|
|
127
|
-
def nested = @of.nested
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
class ObjectNode
|
|
131
|
-
Field = Struct.new(:prop, :key, :node)
|
|
132
|
-
|
|
133
|
-
attr_reader :class_name, :fields
|
|
134
|
-
|
|
135
|
-
def initialize(class_name)
|
|
136
|
-
@class_name = class_name
|
|
137
|
-
@fields = []
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def bare_type = class_name
|
|
141
|
-
|
|
142
|
-
def prop_type
|
|
143
|
-
"T.nilable(#{bare_type})"
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def cast(expr, _depth)
|
|
147
|
-
"#{class_name}.from_h(#{expr})"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def identity? = false
|
|
151
|
-
def non_null? = false
|
|
152
|
-
def nested = self
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
class EnumNode
|
|
156
|
-
attr_reader :class_name, :values
|
|
157
|
-
|
|
158
|
-
def initialize(class_name, values)
|
|
159
|
-
@class_name = class_name
|
|
160
|
-
@values = values
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def bare_type = class_name
|
|
164
|
-
|
|
165
|
-
def prop_type
|
|
166
|
-
"T.nilable(#{bare_type})"
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def cast(expr, _depth)
|
|
170
|
-
"#{class_name}.deserialize(#{expr})"
|
|
171
|
-
end
|
|
15
|
+
# T::Enum), and typed variables (kwargs on execute). Subscriptions are
|
|
16
|
+
# still open.
|
|
17
|
+
#
|
|
18
|
+
# Split across: codegen/scalar_type.rb (the scalar registry),
|
|
19
|
+
# codegen/nodes.rb (the typed IR), codegen/emit.rb (source emission);
|
|
20
|
+
# this file holds the public API and the query walk.
|
|
21
|
+
require_relative "hints"
|
|
22
|
+
require_relative "inflect"
|
|
23
|
+
require_relative "selection"
|
|
24
|
+
require_relative "codegen/enum_type"
|
|
25
|
+
require_relative "codegen/scalar_type"
|
|
26
|
+
require_relative "codegen/nodes"
|
|
27
|
+
require_relative "codegen/emit"
|
|
172
28
|
|
|
173
|
-
|
|
29
|
+
class GraphWeaver::Codegen
|
|
30
|
+
include GraphWeaver::Inflect
|
|
31
|
+
include GraphWeaver::Selection
|
|
32
|
+
include Emit
|
|
33
|
+
|
|
34
|
+
attr_reader :module_name
|
|
35
|
+
|
|
36
|
+
# A client is anything responding to `execute(query, variables:)`
|
|
37
|
+
# whose result `to_h`s into {"data" => ..., "errors" => ...} — a
|
|
38
|
+
# GraphWeaver::Client, a transport, a schema class, a fake.
|
|
39
|
+
#
|
|
40
|
+
# client: (a constant, or its name as a string) becomes the generated
|
|
41
|
+
# module's baked default; when omitted, generated code falls back to
|
|
42
|
+
# the app default (GraphWeaver.client=). module_name:
|
|
43
|
+
# defaults to the operation's
|
|
44
|
+
# name; default_module_name: is parse's container-scoped fallback (file
|
|
45
|
+
# generation stays strict — a checked-in file deserves a deliberate
|
|
46
|
+
# name). scalars:/enums:/types: are client-scoped overlays consulted
|
|
47
|
+
# before the global registries (ScalarType, EnumType, and arrays of
|
|
48
|
+
# mixin modules, each keyed by GraphQL name).
|
|
49
|
+
def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
|
|
50
|
+
scalars: nil, enums: nil, types: nil)
|
|
51
|
+
@schema = schema
|
|
52
|
+
@query = query.strip
|
|
53
|
+
@module_name = module_name
|
|
54
|
+
@default_module_name = default_module_name
|
|
55
|
+
@scalars = scalars || {}
|
|
56
|
+
@enums = enums || {}
|
|
57
|
+
@types = types || {}
|
|
58
|
+
@client_const = self.class.client_const(client)
|
|
174
59
|
|
|
175
|
-
|
|
176
|
-
|
|
60
|
+
if client && @client_const.nil?
|
|
61
|
+
# a live object can't be spelled in generated source — parse can
|
|
62
|
+
# set one via the module's writer, but file generation cannot
|
|
63
|
+
raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
|
|
177
64
|
end
|
|
178
|
-
|
|
179
|
-
def serialize_identity? = false
|
|
180
|
-
def non_null? = false
|
|
181
|
-
def nested = self
|
|
182
65
|
end
|
|
183
66
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
67
|
+
# The constant name a client can be referenced by in generated
|
|
68
|
+
# source — nil when it can't be (live objects, anonymous modules).
|
|
69
|
+
def self.client_const(client)
|
|
70
|
+
case client
|
|
71
|
+
when String then client
|
|
72
|
+
when Module then client.name
|
|
190
73
|
end
|
|
191
|
-
|
|
192
|
-
def bare_type = "#{class_name}::Type"
|
|
193
|
-
|
|
194
|
-
def prop_type
|
|
195
|
-
"T.nilable(#{bare_type})"
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def cast(expr, _depth)
|
|
199
|
-
"#{class_name}.from_h(#{expr})"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def identity? = false
|
|
203
|
-
def non_null? = false
|
|
204
|
-
def nested = self
|
|
205
74
|
end
|
|
206
75
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
# execution, or an HttpExecutor for a remote endpoint. The generated
|
|
211
|
-
# execute also accepts an executor: override at runtime.
|
|
212
|
-
def initialize(schema:, executor_const:, query:, module_name:)
|
|
213
|
-
@schema = schema
|
|
214
|
-
@executor_const = executor_const
|
|
215
|
-
@query = query.strip
|
|
216
|
-
@module_name = module_name
|
|
76
|
+
# one-step shorthand
|
|
77
|
+
def self.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
|
|
78
|
+
new(schema:, query:, module_name:, client:, scalars:, enums:, types:).generate
|
|
217
79
|
end
|
|
218
80
|
|
|
219
81
|
# Development convenience: generate + eval in one step, no build
|
|
220
82
|
# artifact or checked-in file. Same runtime semantics as the generated
|
|
221
83
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
84
|
+
# Evaluates into an anonymous container, so no global constants leak;
|
|
85
|
+
# client: additionally accepts a live object (set via .client=).
|
|
86
|
+
def self.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
|
|
87
|
+
client_const = client_const(client)
|
|
88
|
+
|
|
89
|
+
codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
|
|
90
|
+
scalars:, enums:, types:)
|
|
91
|
+
source = codegen.generate
|
|
92
|
+
|
|
93
|
+
container = Module.new
|
|
94
|
+
container.module_eval(source, "(graph_weaver)", 1)
|
|
95
|
+
mod = container.const_get(codegen.module_name)
|
|
96
|
+
GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
|
|
97
|
+
# live objects (or anonymous modules) can't be referenced from
|
|
98
|
+
# generated source — set them via the module's writer instead
|
|
99
|
+
mod.client = client if client && client_const.nil?
|
|
100
|
+
mod
|
|
226
101
|
end
|
|
227
102
|
|
|
228
103
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
229
104
|
|
|
105
|
+
# Names that cannot appear bare in generated Ruby: keywords aren't
|
|
106
|
+
# valid identifiers, and the struct's own generated methods would be
|
|
107
|
+
# silently replaced by a same-named prop reader.
|
|
108
|
+
RUBY_KEYWORDS = %w[
|
|
109
|
+
alias and begin break case class def defined? do else elsif end
|
|
110
|
+
ensure false for if in module next nil not or redo rescue retry
|
|
111
|
+
return self super then true undef unless until when while yield
|
|
112
|
+
BEGIN END __FILE__ __LINE__ __ENCODING__
|
|
113
|
+
].to_set.freeze
|
|
114
|
+
GENERATED_METHODS = %w[serialize to_h].to_set.freeze
|
|
115
|
+
|
|
230
116
|
def generate
|
|
231
|
-
|
|
117
|
+
begin
|
|
118
|
+
errors = @schema.validate(@query)
|
|
119
|
+
rescue GraphQL::ParseError => e
|
|
120
|
+
# unparseable queries wrap like invalid ones — everything raised
|
|
121
|
+
# here descends from GraphWeaver::Error
|
|
122
|
+
raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
|
|
123
|
+
end
|
|
232
124
|
if errors.any?
|
|
233
|
-
raise
|
|
125
|
+
raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
|
|
234
126
|
end
|
|
235
127
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.grep(GraphQL::Language::Nodes::FragmentDefinition)
|
|
239
|
-
.to_h { |fragment| [fragment.name, fragment] }
|
|
128
|
+
validate_registrations!
|
|
129
|
+
|
|
240
130
|
@variable_enums = {}
|
|
131
|
+
@variable_inputs = {}
|
|
132
|
+
@mapped_enums = {}
|
|
133
|
+
# requires the generated file needs (custom scalars, enum mappings,
|
|
134
|
+
# type helpers all contribute)
|
|
135
|
+
@requires = []
|
|
241
136
|
|
|
242
|
-
operation =
|
|
243
|
-
root_type =
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
137
|
+
operation = load_operation(@query)
|
|
138
|
+
root_type = operation_root_type(operation)
|
|
139
|
+
|
|
140
|
+
@module_name ||= operation.name || @default_module_name
|
|
141
|
+
unless @module_name
|
|
142
|
+
raise ArgumentError, "module_name: required for anonymous operations"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# generated source is eval'd by parse — never let a name inject code
|
|
146
|
+
unless @module_name.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
147
|
+
raise ArgumentError, "module_name: must be a constant name, got #{@module_name.inspect}"
|
|
247
148
|
end
|
|
248
149
|
|
|
249
150
|
variables = operation.variables.map do |var|
|
|
@@ -251,7 +152,14 @@ class GraphWeaver::Codegen
|
|
|
251
152
|
# a variable is optional when nullable or defaulted; optional kwargs
|
|
252
153
|
# default to nil and are omitted from the wire
|
|
253
154
|
required = node.non_null? && var.default_value.nil?
|
|
254
|
-
|
|
155
|
+
kwarg = underscore(var.name)
|
|
156
|
+
# kwargs are declared and forwarded bare in generated source
|
|
157
|
+
if RUBY_KEYWORDS.include?(kwarg)
|
|
158
|
+
raise GraphWeaver::Error,
|
|
159
|
+
"variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
|
|
160
|
+
"(a Ruby keyword) — rename the variable"
|
|
161
|
+
end
|
|
162
|
+
VarDef.new(kwarg, var.name, node, required)
|
|
255
163
|
end
|
|
256
164
|
|
|
257
165
|
root = object_node(root_type, operation.selections, "Result")
|
|
@@ -262,20 +170,52 @@ class GraphWeaver::Codegen
|
|
|
262
170
|
out << ""
|
|
263
171
|
out << "# Generated by GraphWeaver — do not edit."
|
|
264
172
|
out << ""
|
|
173
|
+
requires = @requires.uniq.sort
|
|
174
|
+
if requires.any?
|
|
175
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
176
|
+
out << ""
|
|
177
|
+
end
|
|
265
178
|
out << "module #{@module_name}"
|
|
266
179
|
out << " extend T::Sig"
|
|
267
180
|
out << ""
|
|
268
|
-
|
|
181
|
+
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
182
|
+
# would terminate the heredoc early — pick a delimiter the query
|
|
183
|
+
# can't collide with
|
|
184
|
+
delimiter = "GRAPHQL"
|
|
185
|
+
delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
|
|
186
|
+
out << " QUERY = T.let(<<~'#{delimiter}', String)"
|
|
269
187
|
@query.each_line { |line| out << " #{line}".rstrip }
|
|
270
|
-
out << "
|
|
188
|
+
out << " #{delimiter}"
|
|
271
189
|
out << ""
|
|
190
|
+
@mapped_enums.each_value do |mapped|
|
|
191
|
+
emit_mapped_enum(mapped, out, 1)
|
|
192
|
+
out << ""
|
|
193
|
+
end
|
|
272
194
|
@variable_enums.each_value do |enum|
|
|
273
195
|
emit_enum(enum, out, 1)
|
|
274
196
|
out << ""
|
|
275
197
|
end
|
|
198
|
+
inputs, cyclic = ordered_inputs
|
|
199
|
+
if cyclic
|
|
200
|
+
# Recursive input types (Hasura bool_exp et al) reference each other,
|
|
201
|
+
# so no definition order satisfies the runtime — forward-declare every
|
|
202
|
+
# class empty, then let the full definitions below reopen with props.
|
|
203
|
+
# eval'd so srb sees only the full bodies (reopening a T::Struct to
|
|
204
|
+
# add props is a static error; adding them at runtime is fine).
|
|
205
|
+
out << " # runtime-only forward declarations: these input types reference"
|
|
206
|
+
out << " # each other, so the full definitions below need the constants"
|
|
207
|
+
out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
|
|
208
|
+
inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
|
|
209
|
+
out << " RUBY"
|
|
210
|
+
out << ""
|
|
211
|
+
end
|
|
212
|
+
inputs.each do |input|
|
|
213
|
+
emit_input(input, out, 1)
|
|
214
|
+
out << ""
|
|
215
|
+
end
|
|
276
216
|
emit_nested(root, out, 1)
|
|
277
217
|
out << ""
|
|
278
|
-
emit_execute(out, variables)
|
|
218
|
+
emit_execute(out, variables, flatten: flatten_input(variables))
|
|
279
219
|
out << "end"
|
|
280
220
|
|
|
281
221
|
out.join("\n") + "\n"
|
|
@@ -283,55 +223,61 @@ class GraphWeaver::Codegen
|
|
|
283
223
|
|
|
284
224
|
private
|
|
285
225
|
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
226
|
+
# A client-scoped registration names a type in a specific schema — a
|
|
227
|
+
# typo'd name would otherwise be a silent no-op, the most confusing
|
|
228
|
+
# failure mode available. Called eagerly by Client#register_* when the
|
|
229
|
+
# schema is already loaded, and again at generation (covers clients
|
|
230
|
+
# whose schema introspects lazily). Global registrations skip this:
|
|
231
|
+
# they may target a different client's server.
|
|
232
|
+
def self.validate_registration!(schema, kind, name)
|
|
233
|
+
return if schema.get_type(name)
|
|
234
|
+
|
|
235
|
+
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
236
|
+
DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
|
|
237
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
238
|
+
raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
|
|
290
239
|
end
|
|
291
240
|
|
|
292
|
-
|
|
293
|
-
|
|
241
|
+
# Structured shape for a schema-validation error: message plus its first
|
|
242
|
+
# source location, so ValidationError#errors is inspectable.
|
|
243
|
+
def validation_detail(error)
|
|
244
|
+
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
245
|
+
{ message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
|
|
294
246
|
end
|
|
295
247
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
# scope for this spike).
|
|
300
|
-
def gather(type, selections, out = {})
|
|
301
|
-
selections.each do |selection|
|
|
302
|
-
case selection
|
|
303
|
-
when GraphQL::Language::Nodes::Field
|
|
304
|
-
(out[selection.alias || selection.name] ||= []) << selection
|
|
305
|
-
when GraphQL::Language::Nodes::InlineFragment
|
|
306
|
-
gather(type, selection.selections, out) if applies?(selection.type&.name, type)
|
|
307
|
-
when GraphQL::Language::Nodes::FragmentSpread
|
|
308
|
-
fragment = @fragments.fetch(selection.name) do
|
|
309
|
-
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
310
|
-
end
|
|
311
|
-
gather(type, fragment.selections, out) if applies?(fragment.type.name, type)
|
|
312
|
-
else
|
|
313
|
-
raise NotImplementedError, "unsupported selection: #{selection.class}"
|
|
314
|
-
end
|
|
248
|
+
def validate_registrations!
|
|
249
|
+
{ "enum" => @enums, "scalar" => @scalars, "type" => @types }.each do |kind, registry|
|
|
250
|
+
registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
|
|
315
251
|
end
|
|
316
|
-
|
|
317
|
-
out
|
|
318
252
|
end
|
|
319
253
|
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
|
|
254
|
+
# The Relay convention — an operation whose only variable is a required
|
|
255
|
+
# input object — reads better flattened: the input's fields become
|
|
256
|
+
# execute's kwargs directly, and the wrapping level is rebuilt on the
|
|
257
|
+
# wire. Multi-variable (or nullable-input) operations keep the
|
|
258
|
+
# variable-per-kwarg surface.
|
|
259
|
+
def flatten_input(variables)
|
|
260
|
+
return unless variables.size == 1
|
|
324
261
|
|
|
325
|
-
|
|
326
|
-
return
|
|
262
|
+
var = variables.first
|
|
263
|
+
return unless var.required && var.node.is_a?(NonNull)
|
|
327
264
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
265
|
+
input = var.node.of
|
|
266
|
+
input if input.is_a?(InputNode)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Selection#each_field, collected by result key (codegen groups
|
|
270
|
+
# repeated selections of one field so it can merge them)
|
|
271
|
+
def gather(type, selections)
|
|
272
|
+
out = {}
|
|
273
|
+
each_field(type, selections) { |key, node| (out[key] ||= []) << node }
|
|
274
|
+
out
|
|
331
275
|
end
|
|
332
276
|
|
|
333
277
|
def object_node(type, selections, class_name)
|
|
334
278
|
node = ObjectNode.new(class_name)
|
|
279
|
+
node.graphql_type = type.graphql_name
|
|
280
|
+
node.mixins = type_mixins(type.graphql_name)
|
|
335
281
|
taken = [class_name]
|
|
336
282
|
|
|
337
283
|
gather(type, selections).each do |key, field_nodes|
|
|
@@ -339,7 +285,7 @@ class GraphWeaver::Codegen
|
|
|
339
285
|
prop = underscore(key)
|
|
340
286
|
|
|
341
287
|
child = if field_name == "__typename"
|
|
342
|
-
NonNull.new(
|
|
288
|
+
NonNull.new(scalar_node("String"))
|
|
343
289
|
else
|
|
344
290
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
345
291
|
sub_selections = field_nodes.flat_map(&:selections)
|
|
@@ -349,38 +295,116 @@ class GraphWeaver::Codegen
|
|
|
349
295
|
name = pick_name(core.graphql_name, key, taken)
|
|
350
296
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
351
297
|
when "UNION", "INTERFACE"
|
|
352
|
-
|
|
353
|
-
|
|
298
|
+
conditions = concrete_conditions(core, sub_selections)
|
|
299
|
+
bare = bare_fields(sub_selections) - ["__typename"]
|
|
300
|
+
|
|
301
|
+
if conditions.empty? && core.kind.name == "INTERFACE"
|
|
302
|
+
# interface-level fields only — every member shares them, so
|
|
303
|
+
# one struct suffices and no __typename dispatch is needed
|
|
304
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
305
|
+
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
306
|
+
elsif conditions.size == 1 && bare.empty? &&
|
|
307
|
+
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
308
|
+
# a single `... on X` condition: narrow to X's struct — nil
|
|
309
|
+
# when the runtime type doesn't match (narrowing filters).
|
|
310
|
+
# Narrowing reads "no fields came back" as "type didn't
|
|
311
|
+
# match", so a fragment whose every field hides behind
|
|
312
|
+
# @skip/@include would make a real match indistinguishable
|
|
313
|
+
# from a miss ({} either way) — refuse rather than guess.
|
|
314
|
+
unless unconditional_field?(member, sub_selections)
|
|
315
|
+
raise GraphWeaver::Error,
|
|
316
|
+
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
317
|
+
"@skip/@include — an all-conditional selection makes a match indistinguishable from nil"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
name = pick_name(member.graphql_name, key, taken)
|
|
321
|
+
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
322
|
+
else
|
|
323
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
324
|
+
type_ref(field_type) { union_node(core, sub_selections, name) }
|
|
325
|
+
end
|
|
354
326
|
when "ENUM"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
327
|
+
if (mapped = mapped_enum_node(core))
|
|
328
|
+
type_ref(field_type) { mapped }
|
|
329
|
+
else
|
|
330
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
331
|
+
# sorted so output is deterministic across schema sources
|
|
332
|
+
# (SDL round-trips reorder values alphabetically)
|
|
333
|
+
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
334
|
+
end
|
|
359
335
|
when "SCALAR"
|
|
360
|
-
type_ref(field_type) {
|
|
336
|
+
type_ref(field_type) { scalar_node(core.graphql_name) }
|
|
361
337
|
else
|
|
362
|
-
raise
|
|
338
|
+
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
363
339
|
end
|
|
364
340
|
end
|
|
365
341
|
|
|
342
|
+
# a field under @skip/@include may be absent from the response no
|
|
343
|
+
# matter what the schema says — its type must admit nil
|
|
344
|
+
if field_nodes.any? { |n| n.directives.any? { |d| %w[skip include].include?(d.name) } }
|
|
345
|
+
child = child.of if child.is_a?(NonNull)
|
|
346
|
+
end
|
|
347
|
+
|
|
366
348
|
node.fields << ObjectNode::Field.new(prop, key, child)
|
|
367
349
|
end
|
|
368
350
|
|
|
369
351
|
node
|
|
370
352
|
end
|
|
371
353
|
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
354
|
+
# The concrete type conditions a selection mentions (inline fragments
|
|
355
|
+
# and named spreads), minus conditions naming the abstract type itself.
|
|
356
|
+
def concrete_conditions(core, selections)
|
|
357
|
+
selections.filter_map do |selection|
|
|
358
|
+
case selection
|
|
359
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
360
|
+
selection.type&.name
|
|
361
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
362
|
+
@fragments.fetch(selection.name).type.name
|
|
363
|
+
end
|
|
364
|
+
end.uniq - [core.graphql_name]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# result keys selected as plain fields (outside any type condition)
|
|
368
|
+
def bare_fields(selections)
|
|
369
|
+
selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# does the flattened selection (as seen by member) include at least one
|
|
373
|
+
# field guaranteed to be present in a matching response?
|
|
374
|
+
def unconditional_field?(member, selections)
|
|
375
|
+
each_field(member, selections) do |_key, node|
|
|
376
|
+
return true if node.directives.none? { |d| %w[skip include].include?(d.name) }
|
|
377
|
+
end
|
|
378
|
+
false
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
|
|
382
|
+
# whenever the runtime type doesn't match, whatever the schema promises
|
|
383
|
+
def nilable_type_ref(type, &core)
|
|
384
|
+
case type.kind.name
|
|
385
|
+
when "NON_NULL"
|
|
386
|
+
nilable_type_ref(type.of_type, &core)
|
|
387
|
+
when "LIST"
|
|
388
|
+
List.new(nilable_type_ref(type.of_type, &core))
|
|
389
|
+
else
|
|
390
|
+
core.call
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Abstract types (unions AND interfaces) whose selections vary by
|
|
395
|
+
# concrete type: one member struct per possible type; wire dispatch
|
|
396
|
+
# reads __typename, so the query must select it. For interfaces, the
|
|
397
|
+
# interface's own field selections gather into every member.
|
|
376
398
|
def union_node(type, selections, class_name)
|
|
377
399
|
unless gather(type, selections).key?("__typename")
|
|
378
|
-
raise ArgumentError,
|
|
400
|
+
raise ArgumentError,
|
|
401
|
+
"select __typename on #{type.graphql_name} so #{class_name} can dispatch — " \
|
|
402
|
+
"or narrow to a single `... on Type` condition (no dispatch needed)"
|
|
379
403
|
end
|
|
380
404
|
|
|
381
405
|
# sorted so output is deterministic across schema sources
|
|
382
406
|
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
383
|
-
[possible.graphql_name, object_node(possible, selections, possible.graphql_name)]
|
|
407
|
+
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
384
408
|
end
|
|
385
409
|
|
|
386
410
|
UnionNode.new(class_name, members)
|
|
@@ -395,173 +419,144 @@ class GraphWeaver::Codegen
|
|
|
395
419
|
when GraphQL::Language::Nodes::ListType
|
|
396
420
|
List.new(ast_type_ref(ast_type.of_type))
|
|
397
421
|
when GraphQL::Language::Nodes::TypeName
|
|
398
|
-
|
|
399
|
-
case core.kind.name
|
|
400
|
-
when "SCALAR"
|
|
401
|
-
Scalar.new(core.graphql_name)
|
|
402
|
-
when "ENUM"
|
|
403
|
-
@variable_enums[core.graphql_name] ||=
|
|
404
|
-
EnumNode.new(core.graphql_name, core.values.keys.sort)
|
|
405
|
-
else
|
|
406
|
-
raise NotImplementedError, "unsupported variable kind: #{core.kind.name}"
|
|
407
|
-
end
|
|
422
|
+
variable_core(@schema.get_type(ast_type.name))
|
|
408
423
|
else
|
|
409
|
-
raise
|
|
424
|
+
raise GraphWeaver::Error, "unsupported type node: #{ast_type.class}"
|
|
410
425
|
end
|
|
411
426
|
end
|
|
412
427
|
|
|
413
|
-
#
|
|
414
|
-
def
|
|
415
|
-
case
|
|
416
|
-
when "
|
|
417
|
-
|
|
418
|
-
when "
|
|
419
|
-
|
|
428
|
+
# the input-side core kinds a variable (or input-object field) can have
|
|
429
|
+
def variable_core(core)
|
|
430
|
+
case core.kind.name
|
|
431
|
+
when "SCALAR"
|
|
432
|
+
scalar_node(core.graphql_name)
|
|
433
|
+
when "ENUM"
|
|
434
|
+
mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
|
|
435
|
+
EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
|
|
436
|
+
when "INPUT_OBJECT"
|
|
437
|
+
input_node(core)
|
|
420
438
|
else
|
|
421
|
-
core.
|
|
439
|
+
raise GraphWeaver::Error, "unsupported variable kind: #{core.kind.name}"
|
|
422
440
|
end
|
|
423
441
|
end
|
|
424
442
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
candidate = type_name
|
|
432
|
-
candidate = "#{camelize(key)}#{type_name}" if taken.include?(candidate)
|
|
433
|
-
raise NotImplementedError, "class name collision: #{candidate}" if taken.include?(candidate)
|
|
443
|
+
# A module-level T::Struct per input type, with a serialize method
|
|
444
|
+
# producing the wire hash. Registered once per type — and registered
|
|
445
|
+
# BEFORE its fields walk, so recursive references (Hasura's bool_exp
|
|
446
|
+
# _and/_or/_not) resolve to the same node instead of looping.
|
|
447
|
+
def input_node(core)
|
|
448
|
+
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
434
449
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
450
|
+
node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
|
|
451
|
+
# sorted so output is deterministic across schema sources
|
|
452
|
+
core.arguments.values.sort_by(&:graphql_name).each do |argument|
|
|
453
|
+
prop = underscore(argument.graphql_name)
|
|
454
|
+
# prop readers are bare method calls in the generated struct
|
|
455
|
+
if RUBY_KEYWORDS.include?(prop) || GENERATED_METHODS.include?(prop)
|
|
456
|
+
raise GraphWeaver::Error,
|
|
457
|
+
"input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
|
|
458
|
+
"which collides with #{RUBY_KEYWORDS.include?(prop) ? "a Ruby keyword" : "the struct's generated ##{prop}"}"
|
|
459
|
+
end
|
|
438
460
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
when EnumNode then emit_enum(node, out, indent)
|
|
443
|
-
else emit_object(node, out, indent)
|
|
461
|
+
child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
|
|
462
|
+
required = child.non_null? && !argument.default_value?
|
|
463
|
+
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
444
464
|
end
|
|
465
|
+
node
|
|
445
466
|
end
|
|
446
467
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
468
|
+
# The InputNodes a struct's fields reference, through NON_NULL/LIST
|
|
469
|
+
# wrappers — the edges of the input dependency graph.
|
|
470
|
+
def input_references(node)
|
|
471
|
+
node.fields.filter_map do |field|
|
|
472
|
+
child = field.node
|
|
473
|
+
child = child.of while child.respond_to?(:of)
|
|
474
|
+
child if child.is_a?(InputNode)
|
|
454
475
|
end
|
|
455
|
-
out << "#{pad} end"
|
|
456
|
-
out << "#{pad}end"
|
|
457
476
|
end
|
|
458
477
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
478
|
+
# Input structs in dependency order (referenced types before their
|
|
479
|
+
# referrers) so each emitted const names an already-defined class.
|
|
480
|
+
# Cycles make that impossible — flagged so emission can forward-declare
|
|
481
|
+
# every input class first, then reopen each to add its props.
|
|
482
|
+
def ordered_inputs
|
|
483
|
+
ordered = []
|
|
484
|
+
seen = {} # node => :done | :visiting (bool_exp graphs get big)
|
|
485
|
+
cyclic = T.let(false, T::Boolean)
|
|
486
|
+
|
|
487
|
+
visit = lambda do |node|
|
|
488
|
+
next if seen[node] == :done
|
|
489
|
+
|
|
490
|
+
if seen[node] == :visiting
|
|
491
|
+
cyclic = true
|
|
492
|
+
next
|
|
493
|
+
end
|
|
470
494
|
|
|
471
|
-
|
|
472
|
-
|
|
495
|
+
seen[node] = :visiting
|
|
496
|
+
input_references(node).each(&visit)
|
|
497
|
+
seen[node] = :done
|
|
498
|
+
ordered << node
|
|
473
499
|
end
|
|
500
|
+
@variable_inputs.each_value(&visit)
|
|
474
501
|
|
|
475
|
-
|
|
476
|
-
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(#{node.class_name}) }"
|
|
477
|
-
out << "#{pad} def self.from_h(data)"
|
|
478
|
-
out << "#{pad} new("
|
|
479
|
-
node.fields.each do |field|
|
|
480
|
-
out << "#{pad} #{field.prop}: #{field_cast(field)},"
|
|
481
|
-
end
|
|
482
|
-
out << "#{pad} )"
|
|
483
|
-
out << "#{pad} end"
|
|
484
|
-
out << "#{pad}end"
|
|
502
|
+
[ordered, cyclic]
|
|
485
503
|
end
|
|
486
504
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
node.members.each_value do |member|
|
|
495
|
-
emit_object(member, out, indent + 1)
|
|
496
|
-
out << ""
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
member_names = node.members.values.map(&:class_name)
|
|
500
|
-
type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
|
|
501
|
-
out << "#{pad} Type = T.type_alias { #{type_alias} }"
|
|
502
|
-
out << ""
|
|
503
|
-
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
504
|
-
out << "#{pad} def self.from_h(data)"
|
|
505
|
-
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
506
|
-
node.members.each do |graphql_name, member|
|
|
507
|
-
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
508
|
-
end
|
|
509
|
-
out << "#{pad} else raise \"unexpected __typename: \#{typename}\""
|
|
510
|
-
out << "#{pad} end"
|
|
511
|
-
out << "#{pad} end"
|
|
512
|
-
out << "#{pad}end"
|
|
505
|
+
# Registered helper-module names for a GraphQL type (additive: global
|
|
506
|
+
# registrations plus this client's), collecting their requires.
|
|
507
|
+
def type_mixins(graphql_name)
|
|
508
|
+
entries = [GraphWeaver::Codegen.type_registry[graphql_name], @types[graphql_name]].compact
|
|
509
|
+
entries.each { |entry| @requires.concat(entry[:requires]) }
|
|
510
|
+
entries.flat_map { |entry| entry[:mixins].map(&:name) }
|
|
513
511
|
end
|
|
514
512
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
513
|
+
# The MappedEnum node for a schema enum with a registered app-enum
|
|
514
|
+
# mapping (client overlay first, then the global registry); nil when
|
|
515
|
+
# unregistered, falling back to a generated T::Enum.
|
|
516
|
+
def mapped_enum_node(core)
|
|
517
|
+
enum_type = @enums[core.graphql_name] || GraphWeaver::Codegen.enum_registry[core.graphql_name]
|
|
518
|
+
return unless enum_type
|
|
521
519
|
|
|
522
|
-
|
|
523
|
-
|
|
520
|
+
@requires.concat(enum_type.requires)
|
|
521
|
+
@mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
|
|
522
|
+
end
|
|
524
523
|
|
|
525
|
-
|
|
526
|
-
|
|
524
|
+
# A Scalar node, recording any requires its registered type needs so the
|
|
525
|
+
# generated file can require them (collected across the whole query).
|
|
526
|
+
# Resolution: the client-scoped overlay first, then the global registry.
|
|
527
|
+
def scalar_node(name)
|
|
528
|
+
scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
|
|
529
|
+
@requires.concat(scalar.requires)
|
|
530
|
+
Scalar.new(scalar)
|
|
531
|
+
end
|
|
527
532
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
533
|
+
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
534
|
+
def type_ref(type, &core)
|
|
535
|
+
case type.kind.name
|
|
536
|
+
when "NON_NULL"
|
|
537
|
+
NonNull.new(type_ref(type.of_type, &core))
|
|
538
|
+
when "LIST"
|
|
539
|
+
List.new(type_ref(type.of_type, &core))
|
|
531
540
|
else
|
|
532
|
-
|
|
533
|
-
required.each do |var|
|
|
534
|
-
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
535
|
-
end
|
|
536
|
-
out << " }"
|
|
537
|
-
end
|
|
538
|
-
optional.each do |var|
|
|
539
|
-
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
541
|
+
core.call
|
|
540
542
|
end
|
|
541
|
-
|
|
542
|
-
out << ""
|
|
543
|
-
out << " result = executor.execute(QUERY, variables: variables).to_h"
|
|
544
|
-
out << " if (errors = result[\"errors\"])"
|
|
545
|
-
out << " raise \"query failed: \#{errors.inspect}\""
|
|
546
|
-
out << " end"
|
|
547
|
-
out << ""
|
|
548
|
-
out << " Result.from_h(result.fetch(\"data\"))"
|
|
549
|
-
out << " end"
|
|
550
543
|
end
|
|
551
544
|
|
|
552
|
-
def
|
|
553
|
-
|
|
545
|
+
def unwrap(type)
|
|
546
|
+
type = type.of_type while type.kind.name == "NON_NULL" || type.kind.name == "LIST"
|
|
547
|
+
type
|
|
554
548
|
end
|
|
555
549
|
|
|
556
|
-
|
|
557
|
-
|
|
550
|
+
# GraphQL type names become struct names — camelized, because schemas
|
|
551
|
+
# in the wild use snake_case type names (Hasura, PostGraphile) and a
|
|
552
|
+
# verbatim lowercase name is not a Ruby constant
|
|
553
|
+
def pick_name(type_name, key, taken)
|
|
554
|
+
candidate = camelize(type_name)
|
|
555
|
+
candidate = "#{camelize(key)}#{candidate}" if taken.include?(candidate)
|
|
556
|
+
raise GraphWeaver::Error, "class name collision: #{candidate}" if taken.include?(candidate)
|
|
558
557
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
node.identity? ? raw : node.cast(raw, 1)
|
|
562
|
-
else
|
|
563
|
-
raw = "data[#{field.key.inspect}]"
|
|
564
|
-
node.identity? ? raw : "#{raw}&.then { |v1| #{node.cast("v1", 2)} }"
|
|
565
|
-
end
|
|
558
|
+
taken << candidate
|
|
559
|
+
candidate
|
|
566
560
|
end
|
|
561
|
+
|
|
567
562
|
end
|