graph_weaver 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +256 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +41 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +178 -29
- data/docs/getting_started.md +136 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +55 -38
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +201 -0
- data/lib/graph_weaver/codegen/emit.rb +315 -76
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +123 -68
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +272 -101
- 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/input_struct.rb +59 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +30 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +17 -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 +31 -15
- 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 +289 -38
- metadata +74 -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,11 @@ 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
|
+
require_relative "input_struct"
|
|
22
23
|
require_relative "inflect"
|
|
23
24
|
require_relative "selection"
|
|
25
|
+
require_relative "codegen/enum_type"
|
|
24
26
|
require_relative "codegen/scalar_type"
|
|
25
27
|
require_relative "codegen/nodes"
|
|
26
28
|
require_relative "codegen/emit"
|
|
@@ -32,76 +34,143 @@ class GraphWeaver::Codegen
|
|
|
32
34
|
|
|
33
35
|
attr_reader :module_name
|
|
34
36
|
|
|
35
|
-
#
|
|
36
|
-
# whose result `to_h`s into {"data" => ..., "errors" => ...}
|
|
37
|
+
# A client is anything responding to `execute(query, variables:)`
|
|
38
|
+
# whose result `to_h`s into {"data" => ..., "errors" => ...} — a
|
|
39
|
+
# GraphWeaver::Client, a transport, a schema class, a fake.
|
|
37
40
|
#
|
|
38
|
-
#
|
|
39
|
-
# module's default
|
|
40
|
-
#
|
|
41
|
+
# client: (a constant, or its name as a string) becomes the generated
|
|
42
|
+
# module's baked default; when omitted, generated code falls back to
|
|
43
|
+
# the app default (GraphWeaver.client=). module_name:
|
|
44
|
+
# defaults to the operation's
|
|
41
45
|
# name; default_module_name: is parse's container-scoped fallback (file
|
|
42
46
|
# generation stays strict — a checked-in file deserves a deliberate
|
|
43
|
-
# name).
|
|
44
|
-
|
|
47
|
+
# name). scalars:/enums:/types: are client-scoped overlays consulted
|
|
48
|
+
# before the global registries (ScalarType, EnumType, and arrays of
|
|
49
|
+
# mixin modules, each keyed by GraphQL name). inputs_namespace: is the
|
|
50
|
+
# shared-inputs workflow (see GraphWeaver.generate!): variable types
|
|
51
|
+
# live once in that module and the query module aliases what it uses.
|
|
52
|
+
def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
|
|
53
|
+
scalars: nil, enums: nil, types: nil, inputs_namespace: nil)
|
|
45
54
|
@schema = schema
|
|
46
55
|
@query = query.strip
|
|
47
56
|
@module_name = module_name
|
|
48
57
|
@default_module_name = default_module_name
|
|
49
|
-
@
|
|
58
|
+
@scalars = scalars || {}
|
|
59
|
+
@enums = enums || {}
|
|
60
|
+
@types = types || {}
|
|
61
|
+
@inputs_namespace = inputs_namespace
|
|
62
|
+
@client_const = self.class.client_const(client)
|
|
50
63
|
|
|
51
|
-
if
|
|
64
|
+
if client && @client_const.nil?
|
|
52
65
|
# a live object can't be spelled in generated source — parse can
|
|
53
66
|
# set one via the module's writer, but file generation cannot
|
|
54
|
-
raise ArgumentError, "
|
|
67
|
+
raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
|
|
58
|
-
# The constant name
|
|
71
|
+
# The constant name a client can be referenced by in generated
|
|
59
72
|
# source — nil when it can't be (live objects, anonymous modules).
|
|
60
|
-
def self.
|
|
61
|
-
case
|
|
62
|
-
when String then
|
|
63
|
-
when Module then
|
|
73
|
+
def self.client_const(client)
|
|
74
|
+
case client
|
|
75
|
+
when String then client
|
|
76
|
+
when Module then client.name
|
|
64
77
|
end
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
# one-step shorthand
|
|
68
|
-
def self.generate(schema:, query:, module_name: nil,
|
|
69
|
-
new(schema:, query:, module_name:,
|
|
81
|
+
def self.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
|
|
82
|
+
new(schema:, query:, module_name:, client:, scalars:, enums:, types:).generate
|
|
70
83
|
end
|
|
71
84
|
|
|
72
85
|
# Development convenience: generate + eval in one step, no build
|
|
73
86
|
# artifact or checked-in file. Same runtime semantics as the generated
|
|
74
87
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
75
88
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
76
|
-
#
|
|
77
|
-
def self.parse(schema:, query:, module_name: nil,
|
|
78
|
-
|
|
89
|
+
# client: additionally accepts a live object (set via .client=).
|
|
90
|
+
def self.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
|
|
91
|
+
client_const = client_const(client)
|
|
79
92
|
|
|
80
|
-
codegen = new(schema:, query:, module_name:,
|
|
93
|
+
codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
|
|
94
|
+
scalars:, enums:, types:)
|
|
81
95
|
source = codegen.generate
|
|
82
96
|
|
|
83
97
|
container = Module.new
|
|
84
98
|
container.module_eval(source, "(graph_weaver)", 1)
|
|
85
99
|
mod = container.const_get(codegen.module_name)
|
|
100
|
+
GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
|
|
86
101
|
# live objects (or anonymous modules) can't be referenced from
|
|
87
102
|
# generated source — set them via the module's writer instead
|
|
88
|
-
mod.
|
|
103
|
+
mod.client = client if client && client_const.nil?
|
|
89
104
|
mod
|
|
90
105
|
end
|
|
91
106
|
|
|
107
|
+
# The schema-level variable types this query touched, by GraphQL
|
|
108
|
+
# name — the generate! workflow unions these across queries to decide
|
|
109
|
+
# what the shared inputs module must contain.
|
|
110
|
+
def variable_type_names
|
|
111
|
+
{ inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The shared inputs artifact: the named input/enum types — plus
|
|
115
|
+
# everything they transitively reference — emitted once per schema as
|
|
116
|
+
# a manifest (inputs.rb) plus one file per type under inputs/, so a
|
|
117
|
+
# schema migration diffs only the types it touched. Returns
|
|
118
|
+
# { relative_filename => source }.
|
|
119
|
+
def self.generate_inputs(schema:, module_name:, input_types: [], enum_types: [],
|
|
120
|
+
scalars: nil, enums: nil, types: nil)
|
|
121
|
+
codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
|
|
122
|
+
codegen.generate_inputs(input_types, enum_types)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def generate_inputs(input_types, enum_types)
|
|
126
|
+
unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
127
|
+
raise ArgumentError, "inputs module name must be a constant name, got #{@module_name.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@variable_enums = {}
|
|
131
|
+
@variable_inputs = {}
|
|
132
|
+
@mapped_enums = {}
|
|
133
|
+
@requires = []
|
|
134
|
+
|
|
135
|
+
enum_types.sort.each { |name| variable_core(@schema.get_type(name)) }
|
|
136
|
+
input_types.sort.each { |name| input_node(@schema.get_type(name)) }
|
|
137
|
+
|
|
138
|
+
emit_inputs_files
|
|
139
|
+
end
|
|
140
|
+
|
|
92
141
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
93
142
|
|
|
143
|
+
# Names that cannot appear bare in generated Ruby: keywords aren't
|
|
144
|
+
# valid identifiers, and the struct's own generated methods would be
|
|
145
|
+
# silently replaced by a same-named prop reader.
|
|
146
|
+
RUBY_KEYWORDS = %w[
|
|
147
|
+
alias and begin break case class def defined? do else elsif end
|
|
148
|
+
ensure false for if in module next nil not or redo rescue retry
|
|
149
|
+
return self super then true undef unless until when while yield
|
|
150
|
+
BEGIN END __FILE__ __LINE__ __ENCODING__
|
|
151
|
+
].to_set.freeze
|
|
152
|
+
GENERATED_METHODS = %w[serialize to_h].to_set.freeze
|
|
153
|
+
|
|
94
154
|
def generate
|
|
95
|
-
|
|
155
|
+
begin
|
|
156
|
+
errors = @schema.validate(@query)
|
|
157
|
+
rescue GraphQL::ParseError => e
|
|
158
|
+
# unparseable queries wrap like invalid ones — everything raised
|
|
159
|
+
# here descends from GraphWeaver::Error
|
|
160
|
+
raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
|
|
161
|
+
end
|
|
96
162
|
if errors.any?
|
|
97
163
|
raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
|
|
98
164
|
end
|
|
99
165
|
|
|
166
|
+
validate_registrations!
|
|
167
|
+
|
|
100
168
|
@variable_enums = {}
|
|
101
169
|
@variable_inputs = {}
|
|
102
|
-
@
|
|
103
|
-
# requires
|
|
104
|
-
|
|
170
|
+
@mapped_enums = {}
|
|
171
|
+
# requires the generated file needs (custom scalars, enum mappings,
|
|
172
|
+
# type helpers all contribute)
|
|
173
|
+
@requires = []
|
|
105
174
|
|
|
106
175
|
operation = load_operation(@query)
|
|
107
176
|
root_type = operation_root_type(operation)
|
|
@@ -121,52 +190,52 @@ class GraphWeaver::Codegen
|
|
|
121
190
|
# a variable is optional when nullable or defaulted; optional kwargs
|
|
122
191
|
# default to nil and are omitted from the wire
|
|
123
192
|
required = node.non_null? && var.default_value.nil?
|
|
124
|
-
|
|
193
|
+
kwarg = underscore(var.name)
|
|
194
|
+
# kwargs are declared and forwarded bare in generated source
|
|
195
|
+
if RUBY_KEYWORDS.include?(kwarg)
|
|
196
|
+
raise GraphWeaver::Error,
|
|
197
|
+
"variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
|
|
198
|
+
"(a Ruby keyword) — rename the variable"
|
|
199
|
+
end
|
|
200
|
+
VarDef.new(kwarg, var.name, node, required)
|
|
125
201
|
end
|
|
126
202
|
|
|
127
203
|
root = object_node(root_type, operation.selections, "Result")
|
|
128
204
|
|
|
129
|
-
|
|
130
|
-
out << "# typed: strict"
|
|
131
|
-
out << "# frozen_string_literal: true"
|
|
132
|
-
out << ""
|
|
133
|
-
out << "# Generated by GraphWeaver — do not edit."
|
|
134
|
-
out << ""
|
|
135
|
-
requires = @scalar_requires.uniq.sort
|
|
136
|
-
if requires.any?
|
|
137
|
-
requires.each { |req| out << "require #{req.inspect}" }
|
|
138
|
-
out << ""
|
|
139
|
-
end
|
|
140
|
-
out << "module #{@module_name}"
|
|
141
|
-
out << " extend T::Sig"
|
|
142
|
-
out << ""
|
|
143
|
-
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
144
|
-
# would terminate the heredoc early — pick a delimiter the query
|
|
145
|
-
# can't collide with
|
|
146
|
-
delimiter = "GRAPHQL"
|
|
147
|
-
delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
|
|
148
|
-
out << " QUERY = T.let(<<~'#{delimiter}', String)"
|
|
149
|
-
@query.each_line { |line| out << " #{line}".rstrip }
|
|
150
|
-
out << " #{delimiter}"
|
|
151
|
-
out << ""
|
|
152
|
-
@variable_enums.each_value do |enum|
|
|
153
|
-
emit_enum(enum, out, 1)
|
|
154
|
-
out << ""
|
|
155
|
-
end
|
|
156
|
-
@variable_inputs.each_value do |input|
|
|
157
|
-
emit_input(input, out, 1)
|
|
158
|
-
out << ""
|
|
159
|
-
end
|
|
160
|
-
emit_nested(root, out, 1)
|
|
161
|
-
out << ""
|
|
162
|
-
emit_execute(out, variables)
|
|
163
|
-
out << "end"
|
|
164
|
-
|
|
165
|
-
out.join("\n") + "\n"
|
|
205
|
+
emit_module(root, variables)
|
|
166
206
|
end
|
|
167
207
|
|
|
168
208
|
private
|
|
169
209
|
|
|
210
|
+
# A client-scoped registration names a type in a specific schema — a
|
|
211
|
+
# typo'd name would otherwise be a silent no-op, the most confusing
|
|
212
|
+
# failure mode available. Called eagerly by Client#register_* when the
|
|
213
|
+
# schema is already loaded, and again at generation (covers clients
|
|
214
|
+
# whose schema introspects lazily). Global registrations skip this:
|
|
215
|
+
# they may target a different client's server.
|
|
216
|
+
def self.validate_registration!(schema, kind, name)
|
|
217
|
+
return if schema.get_type(name)
|
|
218
|
+
|
|
219
|
+
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
220
|
+
DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
|
|
221
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
222
|
+
raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Structured shape for a schema-validation error: message plus its first
|
|
226
|
+
# source location, so ValidationError#errors is inspectable.
|
|
227
|
+
def validation_detail(error)
|
|
228
|
+
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
229
|
+
{ message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_registrations!
|
|
233
|
+
{ "enum" => @enums, "scalar" => @scalars, "type" => @types }.each do |kind, registry|
|
|
234
|
+
registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
|
|
170
239
|
# Selection#each_field, collected by result key (codegen groups
|
|
171
240
|
# repeated selections of one field so it can merge them)
|
|
172
241
|
def gather(type, selections)
|
|
@@ -177,6 +246,8 @@ class GraphWeaver::Codegen
|
|
|
177
246
|
|
|
178
247
|
def object_node(type, selections, class_name)
|
|
179
248
|
node = ObjectNode.new(class_name)
|
|
249
|
+
node.graphql_type = type.graphql_name
|
|
250
|
+
node.mixins = type_mixins(type.graphql_name)
|
|
180
251
|
taken = [class_name]
|
|
181
252
|
|
|
182
253
|
gather(type, selections).each do |key, field_nodes|
|
|
@@ -184,7 +255,7 @@ class GraphWeaver::Codegen
|
|
|
184
255
|
prop = underscore(key)
|
|
185
256
|
|
|
186
257
|
child = if field_name == "__typename"
|
|
187
|
-
NonNull.new(
|
|
258
|
+
NonNull.new(scalar_node("String"))
|
|
188
259
|
else
|
|
189
260
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
190
261
|
sub_selections = field_nodes.flat_map(&:selections)
|
|
@@ -194,17 +265,47 @@ class GraphWeaver::Codegen
|
|
|
194
265
|
name = pick_name(core.graphql_name, key, taken)
|
|
195
266
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
196
267
|
when "UNION", "INTERFACE"
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
conditions = concrete_conditions(core, sub_selections)
|
|
269
|
+
bare = bare_fields(sub_selections) - ["__typename"]
|
|
270
|
+
|
|
271
|
+
if conditions.empty? && core.kind.name == "INTERFACE"
|
|
272
|
+
# interface-level fields only — every member shares them, so
|
|
273
|
+
# one struct suffices and no __typename dispatch is needed
|
|
274
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
275
|
+
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
276
|
+
elsif conditions.size == 1 && bare.empty? &&
|
|
277
|
+
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
278
|
+
# a single `... on X` condition: narrow to X's struct — nil
|
|
279
|
+
# when the runtime type doesn't match (narrowing filters).
|
|
280
|
+
# Narrowing reads "no fields came back" as "type didn't
|
|
281
|
+
# match", so a fragment whose every field hides behind
|
|
282
|
+
# @skip/@include would make a real match indistinguishable
|
|
283
|
+
# from a miss ({} either way) — refuse rather than guess.
|
|
284
|
+
unless unconditional_field?(member, sub_selections)
|
|
285
|
+
raise GraphWeaver::Error,
|
|
286
|
+
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
287
|
+
"@skip/@include — an all-conditional selection makes a match indistinguishable from nil"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
name = pick_name(member.graphql_name, key, taken)
|
|
291
|
+
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
292
|
+
else
|
|
293
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
294
|
+
type_ref(field_type) { union_node(core, sub_selections, name) }
|
|
295
|
+
end
|
|
199
296
|
when "ENUM"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
297
|
+
if (mapped = mapped_enum_node(core))
|
|
298
|
+
type_ref(field_type) { mapped }
|
|
299
|
+
else
|
|
300
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
301
|
+
# sorted so output is deterministic across schema sources
|
|
302
|
+
# (SDL round-trips reorder values alphabetically)
|
|
303
|
+
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
304
|
+
end
|
|
204
305
|
when "SCALAR"
|
|
205
306
|
type_ref(field_type) { scalar_node(core.graphql_name) }
|
|
206
307
|
else
|
|
207
|
-
raise
|
|
308
|
+
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
208
309
|
end
|
|
209
310
|
end
|
|
210
311
|
|
|
@@ -220,18 +321,60 @@ class GraphWeaver::Codegen
|
|
|
220
321
|
node
|
|
221
322
|
end
|
|
222
323
|
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
324
|
+
# The concrete type conditions a selection mentions (inline fragments
|
|
325
|
+
# and named spreads), minus conditions naming the abstract type itself.
|
|
326
|
+
def concrete_conditions(core, selections)
|
|
327
|
+
selections.filter_map do |selection|
|
|
328
|
+
case selection
|
|
329
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
330
|
+
selection.type&.name
|
|
331
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
332
|
+
@fragments.fetch(selection.name).type.name
|
|
333
|
+
end
|
|
334
|
+
end.uniq - [core.graphql_name]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# result keys selected as plain fields (outside any type condition)
|
|
338
|
+
def bare_fields(selections)
|
|
339
|
+
selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# does the flattened selection (as seen by member) include at least one
|
|
343
|
+
# field guaranteed to be present in a matching response?
|
|
344
|
+
def unconditional_field?(member, selections)
|
|
345
|
+
each_field(member, selections) do |_key, node|
|
|
346
|
+
return true if node.directives.none? { |d| %w[skip include].include?(d.name) }
|
|
347
|
+
end
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
|
|
352
|
+
# whenever the runtime type doesn't match, whatever the schema promises
|
|
353
|
+
def nilable_type_ref(type, &core)
|
|
354
|
+
case type.kind.name
|
|
355
|
+
when "NON_NULL"
|
|
356
|
+
nilable_type_ref(type.of_type, &core)
|
|
357
|
+
when "LIST"
|
|
358
|
+
List.new(nilable_type_ref(type.of_type, &core))
|
|
359
|
+
else
|
|
360
|
+
core.call
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Abstract types (unions AND interfaces) whose selections vary by
|
|
365
|
+
# concrete type: one member struct per possible type; wire dispatch
|
|
366
|
+
# reads __typename, so the query must select it. For interfaces, the
|
|
367
|
+
# interface's own field selections gather into every member.
|
|
227
368
|
def union_node(type, selections, class_name)
|
|
228
369
|
unless gather(type, selections).key?("__typename")
|
|
229
|
-
raise ArgumentError,
|
|
370
|
+
raise ArgumentError,
|
|
371
|
+
"select __typename on #{type.graphql_name} so #{class_name} can dispatch — " \
|
|
372
|
+
"or narrow to a single `... on Type` condition (no dispatch needed)"
|
|
230
373
|
end
|
|
231
374
|
|
|
232
375
|
# sorted so output is deterministic across schema sources
|
|
233
376
|
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
234
|
-
[possible.graphql_name, object_node(possible, selections, possible.graphql_name)]
|
|
377
|
+
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
235
378
|
end
|
|
236
379
|
|
|
237
380
|
UnionNode.new(class_name, members)
|
|
@@ -248,7 +391,7 @@ class GraphWeaver::Codegen
|
|
|
248
391
|
when GraphQL::Language::Nodes::TypeName
|
|
249
392
|
variable_core(@schema.get_type(ast_type.name))
|
|
250
393
|
else
|
|
251
|
-
raise
|
|
394
|
+
raise GraphWeaver::Error, "unsupported type node: #{ast_type.class}"
|
|
252
395
|
end
|
|
253
396
|
end
|
|
254
397
|
|
|
@@ -258,45 +401,70 @@ class GraphWeaver::Codegen
|
|
|
258
401
|
when "SCALAR"
|
|
259
402
|
scalar_node(core.graphql_name)
|
|
260
403
|
when "ENUM"
|
|
261
|
-
@variable_enums[core.graphql_name] ||=
|
|
262
|
-
EnumNode.new(core.graphql_name, core.values.keys.sort)
|
|
404
|
+
mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
|
|
405
|
+
EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
|
|
263
406
|
when "INPUT_OBJECT"
|
|
264
407
|
input_node(core)
|
|
265
408
|
else
|
|
266
|
-
raise
|
|
409
|
+
raise GraphWeaver::Error, "unsupported variable kind: #{core.kind.name}"
|
|
267
410
|
end
|
|
268
411
|
end
|
|
269
412
|
|
|
270
413
|
# A module-level T::Struct per input type, with a serialize method
|
|
271
|
-
# producing the wire hash. Registered once per type
|
|
272
|
-
#
|
|
414
|
+
# producing the wire hash. Registered once per type — and registered
|
|
415
|
+
# BEFORE its fields walk, so recursive references (Hasura's bool_exp
|
|
416
|
+
# _and/_or/_not) resolve to the same node instead of looping.
|
|
273
417
|
def input_node(core)
|
|
274
418
|
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
275
419
|
|
|
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)
|
|
420
|
+
node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
|
|
282
421
|
# sorted so output is deterministic across schema sources
|
|
283
422
|
core.arguments.values.sort_by(&:graphql_name).each do |argument|
|
|
423
|
+
prop = underscore(argument.graphql_name)
|
|
424
|
+
# prop readers are bare method calls in the generated struct
|
|
425
|
+
if RUBY_KEYWORDS.include?(prop) || GENERATED_METHODS.include?(prop)
|
|
426
|
+
raise GraphWeaver::Error,
|
|
427
|
+
"input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
|
|
428
|
+
"which collides with #{RUBY_KEYWORDS.include?(prop) ? "a Ruby keyword" : "the struct's generated ##{prop}"}"
|
|
429
|
+
end
|
|
430
|
+
|
|
284
431
|
child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
|
|
285
432
|
required = child.non_null? && !argument.default_value?
|
|
286
|
-
node.fields << InputNode::Field.new(
|
|
287
|
-
underscore(argument.graphql_name), argument.graphql_name, child, required
|
|
288
|
-
)
|
|
433
|
+
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
289
434
|
end
|
|
435
|
+
node
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The InputNodes a struct's fields reference, through NON_NULL/LIST
|
|
439
|
+
# wrappers — the edges of the input dependency graph.
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Registered helper-module names for a GraphQL type (additive: global
|
|
443
|
+
# registrations plus this client's), collecting their requires.
|
|
444
|
+
def type_mixins(graphql_name)
|
|
445
|
+
entries = [GraphWeaver::Codegen.type_registry[graphql_name], @types[graphql_name]].compact
|
|
446
|
+
entries.each { |entry| @requires.concat(entry[:requires]) }
|
|
447
|
+
entries.flat_map { |entry| entry[:mixins].map(&:name) }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# The MappedEnum node for a schema enum with a registered app-enum
|
|
451
|
+
# mapping (client overlay first, then the global registry); nil when
|
|
452
|
+
# unregistered, falling back to a generated T::Enum.
|
|
453
|
+
def mapped_enum_node(core)
|
|
454
|
+
enum_type = @enums[core.graphql_name] || GraphWeaver::Codegen.enum_registry[core.graphql_name]
|
|
455
|
+
return unless enum_type
|
|
290
456
|
|
|
291
|
-
@
|
|
292
|
-
@
|
|
457
|
+
@requires.concat(enum_type.requires)
|
|
458
|
+
@mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
|
|
293
459
|
end
|
|
294
460
|
|
|
295
461
|
# A Scalar node, recording any requires its registered type needs so the
|
|
296
462
|
# generated file can require them (collected across the whole query).
|
|
463
|
+
# Resolution: the client-scoped overlay first, then the global registry.
|
|
297
464
|
def scalar_node(name)
|
|
298
|
-
@
|
|
299
|
-
|
|
465
|
+
scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
|
|
466
|
+
@requires.concat(scalar.requires)
|
|
467
|
+
Scalar.new(scalar)
|
|
300
468
|
end
|
|
301
469
|
|
|
302
470
|
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
@@ -316,10 +484,13 @@ class GraphWeaver::Codegen
|
|
|
316
484
|
type
|
|
317
485
|
end
|
|
318
486
|
|
|
487
|
+
# GraphQL type names become struct names — camelized, because schemas
|
|
488
|
+
# in the wild use snake_case type names (Hasura, PostGraphile) and a
|
|
489
|
+
# verbatim lowercase name is not a Ruby constant
|
|
319
490
|
def pick_name(type_name, key, taken)
|
|
320
|
-
candidate = type_name
|
|
321
|
-
candidate = "#{camelize(key)}#{
|
|
322
|
-
raise
|
|
491
|
+
candidate = camelize(type_name)
|
|
492
|
+
candidate = "#{camelize(key)}#{candidate}" if taken.include?(candidate)
|
|
493
|
+
raise GraphWeaver::Error, "class name collision: #{candidate}" if taken.include?(candidate)
|
|
323
494
|
|
|
324
495
|
taken << candidate
|
|
325
496
|
candidate
|