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
|
@@ -5,10 +5,244 @@
|
|
|
5
5
|
# Mixed into Codegen — methods run with the generator instance state.
|
|
6
6
|
class GraphWeaver::Codegen
|
|
7
7
|
module Emit
|
|
8
|
+
include Kernel # for sorbet: hosts are Objects
|
|
8
9
|
include GraphWeaver::Inflect
|
|
9
10
|
|
|
10
11
|
private
|
|
11
12
|
|
|
13
|
+
# The Relay convention — an operation whose only variable is a required
|
|
14
|
+
# input object — reads better flattened: the input's fields become
|
|
15
|
+
# execute's kwargs directly, and the wrapping level is rebuilt on the
|
|
16
|
+
# wire. Multi-variable (or nullable-input) operations keep the
|
|
17
|
+
# variable-per-kwarg surface.
|
|
18
|
+
def flatten_input(variables)
|
|
19
|
+
return unless variables.size == 1
|
|
20
|
+
|
|
21
|
+
var = variables.first
|
|
22
|
+
return unless var.required && var.node.is_a?(NonNull)
|
|
23
|
+
|
|
24
|
+
input = var.node.of
|
|
25
|
+
input if input.is_a?(InputNode)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def input_references(node)
|
|
29
|
+
node.fields.filter_map do |field|
|
|
30
|
+
child = field.node
|
|
31
|
+
child = child.of while child.respond_to?(:of)
|
|
32
|
+
child if child.is_a?(InputNode)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Input structs in dependency order (referenced types before their
|
|
37
|
+
# referrers) so each emitted const names an already-defined class.
|
|
38
|
+
# Cycles make that impossible — flagged so emission can forward-declare
|
|
39
|
+
# every input class first, then reopen each to add its props.
|
|
40
|
+
def ordered_inputs
|
|
41
|
+
ordered = []
|
|
42
|
+
seen = {} # node => :done | :visiting (bool_exp graphs get big)
|
|
43
|
+
cyclic = T.let(false, T::Boolean)
|
|
44
|
+
|
|
45
|
+
visit = lambda do |node|
|
|
46
|
+
next if seen[node] == :done
|
|
47
|
+
|
|
48
|
+
if seen[node] == :visiting
|
|
49
|
+
cyclic = true
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
seen[node] = :visiting
|
|
54
|
+
input_references(node).each(&visit)
|
|
55
|
+
seen[node] = :done
|
|
56
|
+
ordered << node
|
|
57
|
+
end
|
|
58
|
+
@variable_inputs.each_value(&visit)
|
|
59
|
+
|
|
60
|
+
[ordered, cyclic]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Mapped-enum tables, generated enums, and input structs
|
|
64
|
+
# (dependency-ordered, forward-declared when cyclic) — inline in the
|
|
65
|
+
# module that needs them, or once in the shared inputs module.
|
|
66
|
+
def emit_variable_types(out)
|
|
67
|
+
@mapped_enums.each_value do |mapped|
|
|
68
|
+
emit_mapped_enum(mapped, out, 1)
|
|
69
|
+
out << ""
|
|
70
|
+
end
|
|
71
|
+
@variable_enums.each_value do |enum|
|
|
72
|
+
emit_enum(enum, out, 1)
|
|
73
|
+
out << ""
|
|
74
|
+
end
|
|
75
|
+
inputs, cyclic = ordered_inputs
|
|
76
|
+
if cyclic
|
|
77
|
+
# Recursive input types (Hasura bool_exp et al) reference each other,
|
|
78
|
+
# so no definition order satisfies the runtime — forward-declare every
|
|
79
|
+
# class empty, then let the full definitions below reopen with props.
|
|
80
|
+
# eval'd so srb sees only the full bodies (reopening a T::Struct to
|
|
81
|
+
# add props is a static error; adding them at runtime is fine).
|
|
82
|
+
out << " # runtime-only forward declarations: these input types reference"
|
|
83
|
+
out << " # each other, so the full definitions below need the constants"
|
|
84
|
+
out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
|
|
85
|
+
inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
|
|
86
|
+
out << " RUBY"
|
|
87
|
+
out << ""
|
|
88
|
+
end
|
|
89
|
+
inputs.each do |input|
|
|
90
|
+
emit_input(input, out, 1)
|
|
91
|
+
out << ""
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# In the shared-inputs workflow the variable types live once in the
|
|
96
|
+
# inputs module; the query module aliases what it uses, so
|
|
97
|
+
# AdoptQuery::AdoptionInput stays a real constant — and shared types
|
|
98
|
+
# keep ONE identity across every module that touches them.
|
|
99
|
+
# Only the shared names THIS module's emission references: variable
|
|
100
|
+
# root types (and, when flattened, the root input's field types)
|
|
101
|
+
# plus every mapped-enum table (result casting reads them too).
|
|
102
|
+
# Nested types stay un-aliased — they live in the inputs module.
|
|
103
|
+
def shared_alias_names(variables, flatten)
|
|
104
|
+
nodes = variables.map(&:node)
|
|
105
|
+
nodes += flatten.fields.map(&:node) if flatten
|
|
106
|
+
|
|
107
|
+
names = @mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] }
|
|
108
|
+
nodes.each do |wrapped|
|
|
109
|
+
node = T.let(wrapped, T.untyped)
|
|
110
|
+
node = node.of while node.is_a?(NonNull) || node.is_a?(List)
|
|
111
|
+
case node
|
|
112
|
+
when EnumNode, InputNode then names << node.class_name
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
names.uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def emit_shared_aliases(out, names)
|
|
119
|
+
return if names.empty?
|
|
120
|
+
|
|
121
|
+
names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
|
|
122
|
+
out << ""
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The shared inputs artifact as files: inputs.rb (the manifest —
|
|
126
|
+
# requires, forward declarations for every struct so definition
|
|
127
|
+
# order never matters, then one require per type file) plus
|
|
128
|
+
# inputs/<type>.rb per enum/mapped-table/input struct.
|
|
129
|
+
def emit_inputs_files
|
|
130
|
+
files = {}
|
|
131
|
+
enum_files = []
|
|
132
|
+
struct_files = []
|
|
133
|
+
|
|
134
|
+
@mapped_enums.each do |graphql_name, mapped|
|
|
135
|
+
enum_files << inputs_file(files, graphql_name) { |out| emit_mapped_enum(mapped, out, 1) }
|
|
136
|
+
end
|
|
137
|
+
@variable_enums.each do |graphql_name, enum|
|
|
138
|
+
enum_files << inputs_file(files, graphql_name) { |out| emit_enum(enum, out, 1) }
|
|
139
|
+
end
|
|
140
|
+
inputs, = ordered_inputs
|
|
141
|
+
inputs.each do |input|
|
|
142
|
+
struct_files << inputs_file(files, input.class_name) { |out| emit_input(input, out, 1) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
manifest = []
|
|
146
|
+
manifest << "# typed: strict"
|
|
147
|
+
manifest << "# frozen_string_literal: true"
|
|
148
|
+
manifest << ""
|
|
149
|
+
manifest << "# Generated by GraphWeaver — do not edit. Shared variable types for"
|
|
150
|
+
manifest << "# this schema, one file per type; query modules alias what they use."
|
|
151
|
+
manifest << ""
|
|
152
|
+
requires = @requires.uniq.sort
|
|
153
|
+
if requires.any?
|
|
154
|
+
requires.each { |req| manifest << "require #{req.inspect}" }
|
|
155
|
+
manifest << ""
|
|
156
|
+
end
|
|
157
|
+
manifest << "module #{@module_name}; end"
|
|
158
|
+
manifest << ""
|
|
159
|
+
enum_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
|
|
160
|
+
if inputs.any?
|
|
161
|
+
manifest << ""
|
|
162
|
+
manifest << "# runtime-only forward declarations: input types reference each"
|
|
163
|
+
manifest << "# other across files, so every constant must exist before any"
|
|
164
|
+
manifest << "# definition loads (srb sees only the full bodies)"
|
|
165
|
+
manifest << "module #{@module_name}"
|
|
166
|
+
manifest << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
|
|
167
|
+
inputs.each { |input| manifest << " class #{input.class_name} < T::Struct; end" }
|
|
168
|
+
manifest << " RUBY"
|
|
169
|
+
manifest << "end"
|
|
170
|
+
manifest << ""
|
|
171
|
+
struct_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
files["inputs.rb"] = manifest.join("\n") + "\n"
|
|
175
|
+
files
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# one type per file, wrapped in the namespace so bare sibling
|
|
179
|
+
# references resolve lexically
|
|
180
|
+
def inputs_file(files, name)
|
|
181
|
+
out = []
|
|
182
|
+
out << "# typed: strict"
|
|
183
|
+
out << "# frozen_string_literal: true"
|
|
184
|
+
out << ""
|
|
185
|
+
out << "# Generated by GraphWeaver — do not edit."
|
|
186
|
+
out << ""
|
|
187
|
+
out << "module #{@module_name}"
|
|
188
|
+
yield(out)
|
|
189
|
+
out << "end"
|
|
190
|
+
|
|
191
|
+
file = "inputs/#{GraphWeaver::Inflect.underscore(name)}.rb"
|
|
192
|
+
files[file] = out.join("\n") + "\n"
|
|
193
|
+
file
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The whole generated file: header, requires, the QUERY heredoc,
|
|
197
|
+
# enum tables, input structs (dependency-ordered, forward-declared
|
|
198
|
+
# when cyclic), the Result tree, and execute — assembled from the
|
|
199
|
+
# generator's walked state.
|
|
200
|
+
def emit_module(root, variables)
|
|
201
|
+
flatten = flatten_input(variables)
|
|
202
|
+
aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
|
|
203
|
+
|
|
204
|
+
out = []
|
|
205
|
+
out << "# typed: strict"
|
|
206
|
+
out << "# frozen_string_literal: true"
|
|
207
|
+
out << ""
|
|
208
|
+
out << "# Generated by GraphWeaver — do not edit."
|
|
209
|
+
out << ""
|
|
210
|
+
requires = @requires.uniq.sort
|
|
211
|
+
if requires.any?
|
|
212
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
213
|
+
out << ""
|
|
214
|
+
end
|
|
215
|
+
if aliases.any?
|
|
216
|
+
# the aliases below need the shared module loaded (same directory
|
|
217
|
+
# by the generate! convention)
|
|
218
|
+
out << "require_relative \"inputs\""
|
|
219
|
+
out << ""
|
|
220
|
+
end
|
|
221
|
+
out << "module #{@module_name}"
|
|
222
|
+
out << " extend T::Sig"
|
|
223
|
+
out << ""
|
|
224
|
+
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
225
|
+
# would terminate the heredoc early — pick a delimiter the query
|
|
226
|
+
# can't collide with
|
|
227
|
+
delimiter = "GRAPHQL"
|
|
228
|
+
delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
|
|
229
|
+
out << " QUERY = T.let(<<~'#{delimiter}', String)"
|
|
230
|
+
@query.each_line { |line| out << " #{line}".rstrip }
|
|
231
|
+
out << " #{delimiter}"
|
|
232
|
+
out << ""
|
|
233
|
+
if @inputs_namespace
|
|
234
|
+
emit_shared_aliases(out, aliases)
|
|
235
|
+
else
|
|
236
|
+
emit_variable_types(out)
|
|
237
|
+
end
|
|
238
|
+
emit_nested(root, out, 1)
|
|
239
|
+
out << ""
|
|
240
|
+
emit_execute(out, variables, flatten:)
|
|
241
|
+
out << "end"
|
|
242
|
+
|
|
243
|
+
out.join("\n") + "\n"
|
|
244
|
+
end
|
|
245
|
+
|
|
12
246
|
def emit_nested(node, out, indent)
|
|
13
247
|
case node
|
|
14
248
|
when UnionNode then emit_union(node, out, indent)
|
|
@@ -29,11 +263,30 @@ class GraphWeaver::Codegen
|
|
|
29
263
|
out << "#{pad}end"
|
|
30
264
|
end
|
|
31
265
|
|
|
266
|
+
# module-level wire translation tables for an app-mapped enum
|
|
267
|
+
def emit_mapped_enum(node, out, indent)
|
|
268
|
+
pad = " " * indent
|
|
269
|
+
type = node.bare_type
|
|
270
|
+
prefix = node.const_prefix
|
|
271
|
+
|
|
272
|
+
out << "#{pad}# GraphQL enum #{node.graphql_name} <-> #{type} (registered mapping)"
|
|
273
|
+
out << "#{pad}#{prefix}_FROM_WIRE = T.let({"
|
|
274
|
+
node.mapping.each do |wire, member|
|
|
275
|
+
out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
|
|
276
|
+
end
|
|
277
|
+
out << "#{pad}}.freeze, T::Hash[String, #{type}])"
|
|
278
|
+
out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
|
|
279
|
+
end
|
|
280
|
+
|
|
32
281
|
def emit_object(node, out, indent)
|
|
33
282
|
pad = " " * indent
|
|
34
283
|
|
|
35
284
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
36
285
|
out << "#{pad} extend T::Sig"
|
|
286
|
+
out << "#{pad} include GraphWeaver::Hints"
|
|
287
|
+
node.mixins.each do |mixin|
|
|
288
|
+
out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
|
|
289
|
+
end
|
|
37
290
|
out << ""
|
|
38
291
|
|
|
39
292
|
node.fields.filter_map { |field| field.node.nested }.each do |child|
|
|
@@ -89,56 +342,74 @@ class GraphWeaver::Codegen
|
|
|
89
342
|
out << "#{pad}end"
|
|
90
343
|
end
|
|
91
344
|
|
|
92
|
-
def emit_execute(out, variables)
|
|
93
|
-
out << " @
|
|
345
|
+
def emit_execute(out, variables, flatten: nil)
|
|
346
|
+
out << " @client = T.let(nil, T.untyped)"
|
|
94
347
|
out << ""
|
|
95
348
|
out << " class << self"
|
|
96
349
|
out << " extend T::Sig"
|
|
97
350
|
out << ""
|
|
98
|
-
out << " sig { params(
|
|
99
|
-
out << " attr_writer :
|
|
351
|
+
out << " sig { params(client: T.untyped).void }"
|
|
352
|
+
out << " attr_writer :client"
|
|
100
353
|
out << ""
|
|
101
|
-
out << " # default transport for
|
|
354
|
+
out << " # default client (a GraphWeaver::Client or any transport) for"
|
|
355
|
+
out << " # execute: per-module override, else the app default"
|
|
102
356
|
out << " sig { returns(T.untyped) }"
|
|
103
|
-
out << " def
|
|
104
|
-
out << " @
|
|
357
|
+
out << " def client"
|
|
358
|
+
out << " @client || #{@client_const || "GraphWeaver.client!"}"
|
|
105
359
|
out << " end"
|
|
106
360
|
out << " end"
|
|
107
361
|
out << ""
|
|
108
362
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
363
|
+
# the kwarg surface: the input's fields when flattened, else one
|
|
364
|
+
# kwarg per declared variable — typed identically either way. The
|
|
365
|
+
# per-call client override rides as an optional POSITIONAL arg, so
|
|
366
|
+
# variables keep the entire kwarg namespace (nothing is reserved).
|
|
367
|
+
params = flatten ? flatten.fields.partition(&:required).flatten : variables
|
|
368
|
+
|
|
369
|
+
sig_params = ["client: T.untyped"]
|
|
370
|
+
sig_params += params.map do |param|
|
|
371
|
+
bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
|
|
372
|
+
kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
|
|
373
|
+
"#{kwarg_name(param)}: #{kwarg_type}"
|
|
113
374
|
end
|
|
114
|
-
sig_params << "executor: T.untyped"
|
|
115
375
|
|
|
116
|
-
kwargs =
|
|
117
|
-
kwargs
|
|
376
|
+
kwargs = ["client = nil"]
|
|
377
|
+
kwargs += params.map { |param| param.required ? "#{kwarg_name(param)}:" : "#{kwarg_name(param)}: nil" }
|
|
118
378
|
|
|
119
379
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
120
380
|
# `execute(...).data!` — the typed result, or a raised QueryError.
|
|
121
|
-
forward = (
|
|
381
|
+
forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
|
|
122
382
|
|
|
383
|
+
if flatten
|
|
384
|
+
out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
|
|
385
|
+
end
|
|
123
386
|
out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
|
|
124
387
|
out << " def self.execute(#{kwargs.join(", ")})"
|
|
125
388
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
out << " variables = {}"
|
|
129
|
-
else
|
|
389
|
+
if flatten
|
|
390
|
+
fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
|
|
130
391
|
out << " variables = {"
|
|
131
|
-
|
|
132
|
-
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
133
|
-
end
|
|
392
|
+
out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
|
|
134
393
|
out << " }"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
394
|
+
else
|
|
395
|
+
required, optional = variables.partition(&:required)
|
|
396
|
+
if required.empty?
|
|
397
|
+
out << " variables = {}"
|
|
398
|
+
else
|
|
399
|
+
out << " variables = {"
|
|
400
|
+
required.each do |var|
|
|
401
|
+
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
402
|
+
end
|
|
403
|
+
out << " }"
|
|
404
|
+
end
|
|
405
|
+
optional.each do |var|
|
|
406
|
+
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
407
|
+
end
|
|
138
408
|
end
|
|
139
409
|
|
|
140
410
|
out << ""
|
|
141
|
-
out << "
|
|
411
|
+
out << " transport = GraphWeaver.resolve_transport(client || self.client)"
|
|
412
|
+
out << " raw = transport.execute(QUERY, variables: variables).to_h"
|
|
142
413
|
out << " GraphWeaver::Response[Result].new("
|
|
143
414
|
out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
|
|
144
415
|
out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
|
|
@@ -152,18 +423,17 @@ class GraphWeaver::Codegen
|
|
|
152
423
|
out << " end"
|
|
153
424
|
end
|
|
154
425
|
|
|
426
|
+
# a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
|
|
427
|
+
# InputNode::Field (.prop)
|
|
428
|
+
def kwarg_name(param)
|
|
429
|
+
param.respond_to?(:kwarg) ? param.kwarg : param.prop
|
|
430
|
+
end
|
|
431
|
+
|
|
155
432
|
def variable_serialize(var)
|
|
156
433
|
value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
|
|
157
434
|
var.node.serialize_identity? ? value : var.node.serialize(value, 1)
|
|
158
435
|
end
|
|
159
436
|
|
|
160
|
-
# Structured shape for a schema-validation error: message plus its first
|
|
161
|
-
# source location, so ValidationError#errors is inspectable.
|
|
162
|
-
def validation_detail(error)
|
|
163
|
-
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
164
|
-
{ message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
|
|
165
|
-
end
|
|
166
|
-
|
|
167
437
|
def field_cast(field)
|
|
168
438
|
node = field.node
|
|
169
439
|
|
|
@@ -176,61 +446,30 @@ class GraphWeaver::Codegen
|
|
|
176
446
|
end
|
|
177
447
|
end
|
|
178
448
|
|
|
179
|
-
#
|
|
180
|
-
#
|
|
449
|
+
# A module-level T::Struct per input type: typed consts plus a FIELDS
|
|
450
|
+
# table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
|
|
451
|
+
# coerce live once in the gem, not unrolled per struct (bool_exp
|
|
452
|
+
# schemas pull hundreds of inputs into one module).
|
|
181
453
|
def emit_input(node, out, indent)
|
|
182
454
|
pad = " " * indent
|
|
183
455
|
|
|
184
456
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
185
|
-
out << "#{pad}
|
|
457
|
+
out << "#{pad} include GraphWeaver::InputStruct"
|
|
458
|
+
out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
|
|
186
459
|
out << ""
|
|
187
460
|
node.fields.each do |field|
|
|
188
461
|
default = field.required ? "" : ", default: nil"
|
|
189
462
|
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
|
|
190
463
|
end
|
|
191
464
|
out << ""
|
|
192
|
-
out << "#{pad}
|
|
193
|
-
out << "#{pad}
|
|
194
|
-
out << "#{pad} result = T.let({}, T::Hash[String, T.untyped])"
|
|
195
|
-
node.fields.each do |field|
|
|
196
|
-
value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
|
|
197
|
-
line = "result[#{field.wire.inspect}] = #{value}"
|
|
198
|
-
line += " unless #{field.prop}.nil?" unless field.required
|
|
199
|
-
out << "#{pad} #{line}"
|
|
200
|
-
end
|
|
201
|
-
out << "#{pad} result"
|
|
202
|
-
out << "#{pad} end"
|
|
203
|
-
out << ""
|
|
204
|
-
out << "#{pad} # serialize, under the conventional name"
|
|
205
|
-
out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
|
|
206
|
-
out << "#{pad} def to_h = serialize"
|
|
207
|
-
out << ""
|
|
208
|
-
out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
|
|
209
|
-
out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
|
|
210
|
-
out << "#{pad} # the struct's types are enforced on construction."
|
|
211
|
-
out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
|
|
212
|
-
out << "#{pad} def self.coerce(value)"
|
|
213
|
-
out << "#{pad} return value if value.is_a?(#{node.class_name})"
|
|
214
|
-
out << ""
|
|
215
|
-
out << "#{pad} new("
|
|
465
|
+
out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
|
|
466
|
+
out << "#{pad} FIELDS = T.let(["
|
|
216
467
|
node.fields.each do |field|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
elsif field.required
|
|
221
|
-
"#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
|
|
222
|
-
else
|
|
223
|
-
"#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
|
|
224
|
-
end
|
|
225
|
-
out << "#{pad} #{field.prop}: #{expr},"
|
|
468
|
+
serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
|
|
469
|
+
coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
|
|
470
|
+
out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}),"
|
|
226
471
|
end
|
|
227
|
-
out << "#{pad}
|
|
228
|
-
out << "#{pad} end"
|
|
229
|
-
out << ""
|
|
230
|
-
out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
|
|
231
|
-
out << "#{pad} private_class_method def self.value_at(hash, key)"
|
|
232
|
-
out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
|
|
233
|
-
out << "#{pad} end"
|
|
472
|
+
out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
|
|
234
473
|
out << "#{pad}end"
|
|
235
474
|
end
|
|
236
475
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class GraphWeaver::Codegen
|
|
5
|
+
# How one GraphQL enum maps onto an app-owned T::Enum, so generated
|
|
6
|
+
# code speaks YOUR enum instead of generating one per module:
|
|
7
|
+
#
|
|
8
|
+
# class PetKind < T::Enum
|
|
9
|
+
# enums { Cat = new("cat"); Dog = new("dog") }
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# GraphWeaver.register_enum("Species", PetKind)
|
|
13
|
+
#
|
|
14
|
+
# The wire mapping is inferred by name ("CAT" <-> PetKind::Cat,
|
|
15
|
+
# case/underscore-insensitive against each member's serialized value);
|
|
16
|
+
# map: pins renames explicitly and merges over inference. Every wire
|
|
17
|
+
# value the schema declares must resolve — generation fails naming the
|
|
18
|
+
# gaps — unless fallback: names a member to absorb unknown values
|
|
19
|
+
# (forward-compat for servers that add members; inputs stay strict).
|
|
20
|
+
class EnumType
|
|
21
|
+
attr_reader :graphql_name, :type, :fallback, :requires
|
|
22
|
+
|
|
23
|
+
def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
24
|
+
@graphql_name = graphql_name.to_s
|
|
25
|
+
unless type.is_a?(Class) && type < T::Enum
|
|
26
|
+
raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
|
|
27
|
+
end
|
|
28
|
+
unless type.name
|
|
29
|
+
raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@type = type
|
|
33
|
+
@map = map || {}
|
|
34
|
+
@fallback = fallback
|
|
35
|
+
@requires = Array(requires)
|
|
36
|
+
|
|
37
|
+
if fallback && !type.values.include?(fallback)
|
|
38
|
+
raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# wire value => member for every value the schema declares; raises
|
|
43
|
+
# naming the unmappable ones (unless fallback: absorbs them)
|
|
44
|
+
def mapping_for(wire_values)
|
|
45
|
+
mapping = {}
|
|
46
|
+
missing = []
|
|
47
|
+
|
|
48
|
+
wire_values.each do |wire|
|
|
49
|
+
member = @map[wire] || infer(wire)
|
|
50
|
+
member ? mapping[wire] = member : missing << wire
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if missing.any? && !fallback
|
|
54
|
+
raise GraphWeaver::Error,
|
|
55
|
+
"#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
|
|
56
|
+
"add them, pin with map:, or absorb with fallback:"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
mapping
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
|
|
65
|
+
def infer(wire)
|
|
66
|
+
@type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize(value)
|
|
70
|
+
value.downcase.delete("_")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# Map a GraphQL enum onto an app-owned T::Enum (see EnumType); the
|
|
76
|
+
# global default — client.register_enum scopes to one client.
|
|
77
|
+
def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
78
|
+
enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
82
|
+
def register_enums(mappings)
|
|
83
|
+
mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enum_registry
|
|
87
|
+
@enum_registry ||= {}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Attach app-owned helper modules to every struct generated from a
|
|
91
|
+
# GraphQL type — the logic stays in your code, generation wires it in:
|
|
92
|
+
#
|
|
93
|
+
# GraphWeaver.register_type("Pet", PetHelpers)
|
|
94
|
+
#
|
|
95
|
+
# Or build the mixin inline — the block is module_eval'd into a fresh
|
|
96
|
+
# module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
|
|
97
|
+
# decoration; srb tc can't see into block-defined methods, so prefer
|
|
98
|
+
# a named module where static checking matters:
|
|
99
|
+
#
|
|
100
|
+
# GraphWeaver.register_type("Pet") do
|
|
101
|
+
# def display_name = "#{name} the pet"
|
|
102
|
+
# end
|
|
103
|
+
#
|
|
104
|
+
# Additive: repeated registrations (and client-scoped ones) stack.
|
|
105
|
+
def register_type(graphql_name, *mixins, requires: nil, &block)
|
|
106
|
+
entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
|
|
107
|
+
add_type_helpers(entry, graphql_name, mixins, requires, block)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def type_registry
|
|
111
|
+
@type_registry ||= {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# shared with Client#register_type: build/validate the mixins and
|
|
115
|
+
# append them to a registry entry
|
|
116
|
+
def add_type_helpers(entry, graphql_name, mixins, requires, block)
|
|
117
|
+
mixins = mixins.dup
|
|
118
|
+
mixins << helper_module(graphql_name, block) if block
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
|
|
121
|
+
mixins.each do |mixin|
|
|
122
|
+
unless mixin.is_a?(Module) && mixin.name
|
|
123
|
+
raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
entry[:mixins].concat(mixins)
|
|
128
|
+
entry[:requires].concat(Array(requires))
|
|
129
|
+
entry
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# a block-built mixin needs a name generated files can reference:
|
|
133
|
+
# GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
|
|
134
|
+
def helper_module(graphql_name, block)
|
|
135
|
+
base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
|
|
136
|
+
name = base
|
|
137
|
+
count = 1
|
|
138
|
+
name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
|
|
139
|
+
GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
|
|
140
|
+
end
|
|
141
|
+
private :helper_module
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
module GraphWeaver
|
|
146
|
+
# Home of block-built type helpers (register_type with a block), which
|
|
147
|
+
# need constant names so generated files can reference them.
|
|
148
|
+
module TypeHelpers; end
|
|
149
|
+
end
|