graph_weaver 0.1.0 → 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 +213 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +34 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +139 -29
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +44 -34
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +88 -39
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +109 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +301 -67
- data/lib/graph_weaver/errors.rb +37 -25
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +2 -1
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +19 -14
- data/lib/graph_weaver/schema_loader.rb +156 -20
- data/lib/graph_weaver/selection.rb +3 -3
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +42 -21
- data/lib/graph_weaver/testing/failure.rb +22 -22
- data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
- data/lib/graph_weaver/testing/values.rb +2 -2
- data/lib/graph_weaver/testing.rb +32 -16
- 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 +208 -38
- metadata +73 -5
- data/lib/graph_weaver/faraday_executor.rb +0 -61
- data/lib/graph_weaver/http_executor.rb +0 -44
- data/lib/graph_weaver/testing/rspec.rb +0 -5
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "date"
|
|
5
4
|
require "graphql"
|
|
6
5
|
require "sorbet-runtime"
|
|
7
6
|
|
|
@@ -19,8 +18,10 @@ require "sorbet-runtime"
|
|
|
19
18
|
# Split across: codegen/scalar_type.rb (the scalar registry),
|
|
20
19
|
# codegen/nodes.rb (the typed IR), codegen/emit.rb (source emission);
|
|
21
20
|
# this file holds the public API and the query walk.
|
|
21
|
+
require_relative "hints"
|
|
22
22
|
require_relative "inflect"
|
|
23
23
|
require_relative "selection"
|
|
24
|
+
require_relative "codegen/enum_type"
|
|
24
25
|
require_relative "codegen/scalar_type"
|
|
25
26
|
require_relative "codegen/nodes"
|
|
26
27
|
require_relative "codegen/emit"
|
|
@@ -32,76 +33,106 @@ class GraphWeaver::Codegen
|
|
|
32
33
|
|
|
33
34
|
attr_reader :module_name
|
|
34
35
|
|
|
35
|
-
#
|
|
36
|
-
# whose result `to_h`s into {"data" => ..., "errors" => ...}
|
|
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.
|
|
37
39
|
#
|
|
38
|
-
#
|
|
39
|
-
# module's default
|
|
40
|
-
#
|
|
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
|
|
41
44
|
# name; default_module_name: is parse's container-scoped fallback (file
|
|
42
45
|
# generation stays strict — a checked-in file deserves a deliberate
|
|
43
|
-
# name).
|
|
44
|
-
|
|
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)
|
|
45
51
|
@schema = schema
|
|
46
52
|
@query = query.strip
|
|
47
53
|
@module_name = module_name
|
|
48
54
|
@default_module_name = default_module_name
|
|
49
|
-
@
|
|
55
|
+
@scalars = scalars || {}
|
|
56
|
+
@enums = enums || {}
|
|
57
|
+
@types = types || {}
|
|
58
|
+
@client_const = self.class.client_const(client)
|
|
50
59
|
|
|
51
|
-
if
|
|
60
|
+
if client && @client_const.nil?
|
|
52
61
|
# a live object can't be spelled in generated source — parse can
|
|
53
62
|
# set one via the module's writer, but file generation cannot
|
|
54
|
-
raise ArgumentError, "
|
|
63
|
+
raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
|
|
58
|
-
# The constant name
|
|
67
|
+
# The constant name a client can be referenced by in generated
|
|
59
68
|
# source — nil when it can't be (live objects, anonymous modules).
|
|
60
|
-
def self.
|
|
61
|
-
case
|
|
62
|
-
when String then
|
|
63
|
-
when Module then
|
|
69
|
+
def self.client_const(client)
|
|
70
|
+
case client
|
|
71
|
+
when String then client
|
|
72
|
+
when Module then client.name
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
# one-step shorthand
|
|
68
|
-
def self.generate(schema:, query:, module_name: nil,
|
|
69
|
-
new(schema:, query:, module_name:,
|
|
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
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
# Development convenience: generate + eval in one step, no build
|
|
73
82
|
# artifact or checked-in file. Same runtime semantics as the generated
|
|
74
83
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
75
84
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
76
|
-
#
|
|
77
|
-
def self.parse(schema:, query:, module_name: nil,
|
|
78
|
-
|
|
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)
|
|
79
88
|
|
|
80
|
-
codegen = new(schema:, query:, module_name:,
|
|
89
|
+
codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
|
|
90
|
+
scalars:, enums:, types:)
|
|
81
91
|
source = codegen.generate
|
|
82
92
|
|
|
83
93
|
container = Module.new
|
|
84
94
|
container.module_eval(source, "(graph_weaver)", 1)
|
|
85
95
|
mod = container.const_get(codegen.module_name)
|
|
96
|
+
GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
|
|
86
97
|
# live objects (or anonymous modules) can't be referenced from
|
|
87
98
|
# generated source — set them via the module's writer instead
|
|
88
|
-
mod.
|
|
99
|
+
mod.client = client if client && client_const.nil?
|
|
89
100
|
mod
|
|
90
101
|
end
|
|
91
102
|
|
|
92
103
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
93
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
|
+
|
|
94
116
|
def generate
|
|
95
|
-
|
|
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
|
|
96
124
|
if errors.any?
|
|
97
125
|
raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
|
|
98
126
|
end
|
|
99
127
|
|
|
128
|
+
validate_registrations!
|
|
129
|
+
|
|
100
130
|
@variable_enums = {}
|
|
101
131
|
@variable_inputs = {}
|
|
102
|
-
@
|
|
103
|
-
# requires
|
|
104
|
-
|
|
132
|
+
@mapped_enums = {}
|
|
133
|
+
# requires the generated file needs (custom scalars, enum mappings,
|
|
134
|
+
# type helpers all contribute)
|
|
135
|
+
@requires = []
|
|
105
136
|
|
|
106
137
|
operation = load_operation(@query)
|
|
107
138
|
root_type = operation_root_type(operation)
|
|
@@ -121,7 +152,14 @@ class GraphWeaver::Codegen
|
|
|
121
152
|
# a variable is optional when nullable or defaulted; optional kwargs
|
|
122
153
|
# default to nil and are omitted from the wire
|
|
123
154
|
required = node.non_null? && var.default_value.nil?
|
|
124
|
-
|
|
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)
|
|
125
163
|
end
|
|
126
164
|
|
|
127
165
|
root = object_node(root_type, operation.selections, "Result")
|
|
@@ -132,7 +170,7 @@ class GraphWeaver::Codegen
|
|
|
132
170
|
out << ""
|
|
133
171
|
out << "# Generated by GraphWeaver — do not edit."
|
|
134
172
|
out << ""
|
|
135
|
-
requires = @
|
|
173
|
+
requires = @requires.uniq.sort
|
|
136
174
|
if requires.any?
|
|
137
175
|
requires.each { |req| out << "require #{req.inspect}" }
|
|
138
176
|
out << ""
|
|
@@ -149,17 +187,35 @@ class GraphWeaver::Codegen
|
|
|
149
187
|
@query.each_line { |line| out << " #{line}".rstrip }
|
|
150
188
|
out << " #{delimiter}"
|
|
151
189
|
out << ""
|
|
190
|
+
@mapped_enums.each_value do |mapped|
|
|
191
|
+
emit_mapped_enum(mapped, out, 1)
|
|
192
|
+
out << ""
|
|
193
|
+
end
|
|
152
194
|
@variable_enums.each_value do |enum|
|
|
153
195
|
emit_enum(enum, out, 1)
|
|
154
196
|
out << ""
|
|
155
197
|
end
|
|
156
|
-
|
|
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|
|
|
157
213
|
emit_input(input, out, 1)
|
|
158
214
|
out << ""
|
|
159
215
|
end
|
|
160
216
|
emit_nested(root, out, 1)
|
|
161
217
|
out << ""
|
|
162
|
-
emit_execute(out, variables)
|
|
218
|
+
emit_execute(out, variables, flatten: flatten_input(variables))
|
|
163
219
|
out << "end"
|
|
164
220
|
|
|
165
221
|
out.join("\n") + "\n"
|
|
@@ -167,6 +223,49 @@ class GraphWeaver::Codegen
|
|
|
167
223
|
|
|
168
224
|
private
|
|
169
225
|
|
|
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}"
|
|
239
|
+
end
|
|
240
|
+
|
|
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"] }
|
|
246
|
+
end
|
|
247
|
+
|
|
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) }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
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
|
|
261
|
+
|
|
262
|
+
var = variables.first
|
|
263
|
+
return unless var.required && var.node.is_a?(NonNull)
|
|
264
|
+
|
|
265
|
+
input = var.node.of
|
|
266
|
+
input if input.is_a?(InputNode)
|
|
267
|
+
end
|
|
268
|
+
|
|
170
269
|
# Selection#each_field, collected by result key (codegen groups
|
|
171
270
|
# repeated selections of one field so it can merge them)
|
|
172
271
|
def gather(type, selections)
|
|
@@ -177,6 +276,8 @@ class GraphWeaver::Codegen
|
|
|
177
276
|
|
|
178
277
|
def object_node(type, selections, class_name)
|
|
179
278
|
node = ObjectNode.new(class_name)
|
|
279
|
+
node.graphql_type = type.graphql_name
|
|
280
|
+
node.mixins = type_mixins(type.graphql_name)
|
|
180
281
|
taken = [class_name]
|
|
181
282
|
|
|
182
283
|
gather(type, selections).each do |key, field_nodes|
|
|
@@ -184,7 +285,7 @@ class GraphWeaver::Codegen
|
|
|
184
285
|
prop = underscore(key)
|
|
185
286
|
|
|
186
287
|
child = if field_name == "__typename"
|
|
187
|
-
NonNull.new(
|
|
288
|
+
NonNull.new(scalar_node("String"))
|
|
188
289
|
else
|
|
189
290
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
190
291
|
sub_selections = field_nodes.flat_map(&:selections)
|
|
@@ -194,17 +295,47 @@ class GraphWeaver::Codegen
|
|
|
194
295
|
name = pick_name(core.graphql_name, key, taken)
|
|
195
296
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
196
297
|
when "UNION", "INTERFACE"
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
199
326
|
when "ENUM"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
204
335
|
when "SCALAR"
|
|
205
336
|
type_ref(field_type) { scalar_node(core.graphql_name) }
|
|
206
337
|
else
|
|
207
|
-
raise
|
|
338
|
+
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
208
339
|
end
|
|
209
340
|
end
|
|
210
341
|
|
|
@@ -220,18 +351,60 @@ class GraphWeaver::Codegen
|
|
|
220
351
|
node
|
|
221
352
|
end
|
|
222
353
|
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
227
398
|
def union_node(type, selections, class_name)
|
|
228
399
|
unless gather(type, selections).key?("__typename")
|
|
229
|
-
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)"
|
|
230
403
|
end
|
|
231
404
|
|
|
232
405
|
# sorted so output is deterministic across schema sources
|
|
233
406
|
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
234
|
-
[possible.graphql_name, object_node(possible, selections, possible.graphql_name)]
|
|
407
|
+
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
235
408
|
end
|
|
236
409
|
|
|
237
410
|
UnionNode.new(class_name, members)
|
|
@@ -248,7 +421,7 @@ class GraphWeaver::Codegen
|
|
|
248
421
|
when GraphQL::Language::Nodes::TypeName
|
|
249
422
|
variable_core(@schema.get_type(ast_type.name))
|
|
250
423
|
else
|
|
251
|
-
raise
|
|
424
|
+
raise GraphWeaver::Error, "unsupported type node: #{ast_type.class}"
|
|
252
425
|
end
|
|
253
426
|
end
|
|
254
427
|
|
|
@@ -258,45 +431,103 @@ class GraphWeaver::Codegen
|
|
|
258
431
|
when "SCALAR"
|
|
259
432
|
scalar_node(core.graphql_name)
|
|
260
433
|
when "ENUM"
|
|
261
|
-
@variable_enums[core.graphql_name] ||=
|
|
262
|
-
EnumNode.new(core.graphql_name, core.values.keys.sort)
|
|
434
|
+
mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
|
|
435
|
+
EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
|
|
263
436
|
when "INPUT_OBJECT"
|
|
264
437
|
input_node(core)
|
|
265
438
|
else
|
|
266
|
-
raise
|
|
439
|
+
raise GraphWeaver::Error, "unsupported variable kind: #{core.kind.name}"
|
|
267
440
|
end
|
|
268
441
|
end
|
|
269
442
|
|
|
270
443
|
# A module-level T::Struct per input type, with a serialize method
|
|
271
|
-
# producing the wire hash. Registered once per type
|
|
272
|
-
#
|
|
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.
|
|
273
447
|
def input_node(core)
|
|
274
448
|
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
275
449
|
|
|
276
|
-
|
|
277
|
-
raise NotImplementedError, "recursive input type: #{core.graphql_name}"
|
|
278
|
-
end
|
|
279
|
-
@inputs_in_progress << core.graphql_name
|
|
280
|
-
|
|
281
|
-
node = InputNode.new(core.graphql_name)
|
|
450
|
+
node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
|
|
282
451
|
# sorted so output is deterministic across schema sources
|
|
283
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
|
|
460
|
+
|
|
284
461
|
child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
|
|
285
462
|
required = child.non_null? && !argument.default_value?
|
|
286
|
-
node.fields << InputNode::Field.new(
|
|
287
|
-
underscore(argument.graphql_name), argument.graphql_name, child, required
|
|
288
|
-
)
|
|
463
|
+
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
289
464
|
end
|
|
465
|
+
node
|
|
466
|
+
end
|
|
467
|
+
|
|
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)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
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
|
|
494
|
+
|
|
495
|
+
seen[node] = :visiting
|
|
496
|
+
input_references(node).each(&visit)
|
|
497
|
+
seen[node] = :done
|
|
498
|
+
ordered << node
|
|
499
|
+
end
|
|
500
|
+
@variable_inputs.each_value(&visit)
|
|
501
|
+
|
|
502
|
+
[ordered, cyclic]
|
|
503
|
+
end
|
|
504
|
+
|
|
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) }
|
|
511
|
+
end
|
|
512
|
+
|
|
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
|
|
290
519
|
|
|
291
|
-
@
|
|
292
|
-
@
|
|
520
|
+
@requires.concat(enum_type.requires)
|
|
521
|
+
@mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
|
|
293
522
|
end
|
|
294
523
|
|
|
295
524
|
# A Scalar node, recording any requires its registered type needs so the
|
|
296
525
|
# generated file can require them (collected across the whole query).
|
|
526
|
+
# Resolution: the client-scoped overlay first, then the global registry.
|
|
297
527
|
def scalar_node(name)
|
|
298
|
-
@
|
|
299
|
-
|
|
528
|
+
scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
|
|
529
|
+
@requires.concat(scalar.requires)
|
|
530
|
+
Scalar.new(scalar)
|
|
300
531
|
end
|
|
301
532
|
|
|
302
533
|
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
@@ -316,10 +547,13 @@ class GraphWeaver::Codegen
|
|
|
316
547
|
type
|
|
317
548
|
end
|
|
318
549
|
|
|
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
|
|
319
553
|
def pick_name(type_name, key, taken)
|
|
320
|
-
candidate = type_name
|
|
321
|
-
candidate = "#{camelize(key)}#{
|
|
322
|
-
raise
|
|
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)
|
|
323
557
|
|
|
324
558
|
taken << candidate
|
|
325
559
|
candidate
|