graph_weaver 0.4.4 → 0.5.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/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
|
@@ -10,21 +10,6 @@ class GraphWeaver::Codegen
|
|
|
10
10
|
|
|
11
11
|
private
|
|
12
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
13
|
def input_references(node)
|
|
29
14
|
node.fields.filter_map do |field|
|
|
30
15
|
child = field.node
|
|
@@ -60,178 +45,166 @@ class GraphWeaver::Codegen
|
|
|
60
45
|
[ordered, cyclic]
|
|
61
46
|
end
|
|
62
47
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
48
|
+
# Mapped-enum tables, generated enums, and input structs
|
|
49
|
+
# (dependency-ordered, forward-declared when cyclic) — inline in the
|
|
50
|
+
# module that needs them, unless the shared module already holds them
|
|
51
|
+
# (then the module aliases them instead; see emit_shared_aliases).
|
|
52
|
+
def emit_variable_types(out)
|
|
53
|
+
return if @types_namespace
|
|
54
|
+
|
|
55
|
+
emit_enum_types(out)
|
|
56
|
+
inputs, cyclic = ordered_inputs
|
|
57
|
+
if cyclic
|
|
58
|
+
# Recursive input types (Hasura bool_exp et al) reference each other,
|
|
59
|
+
# so no definition order satisfies the runtime — forward-declare every
|
|
60
|
+
# class empty, then let the full definitions below reopen with props.
|
|
61
|
+
# eval'd so srb sees only the full bodies (reopening a T::Struct to
|
|
62
|
+
# add props is a static error; adding them at runtime is fine).
|
|
63
|
+
out << " # runtime-only forward declarations: these input types reference"
|
|
64
|
+
out << " # each other, so the full definitions below need the constants"
|
|
65
|
+
out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
|
|
66
|
+
inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
|
|
67
|
+
out << " RUBY"
|
|
68
|
+
out << ""
|
|
116
69
|
end
|
|
70
|
+
inputs.each do |input|
|
|
71
|
+
emit_input(input, out, 1)
|
|
72
|
+
out << ""
|
|
73
|
+
end
|
|
74
|
+
end
|
|
117
75
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
76
|
+
# Every schema enum this walk touched, at module level: wire tables for
|
|
77
|
+
# the ones mapped onto an app enum, a T::Enum for the rest.
|
|
78
|
+
def emit_enum_types(out, indent = 1)
|
|
79
|
+
@mapped_enums.each_value do |mapped|
|
|
80
|
+
emit_mapped_enum(mapped, out, indent)
|
|
122
81
|
out << ""
|
|
123
82
|
end
|
|
83
|
+
@enums.each_value do |enum|
|
|
84
|
+
emit_enum(enum, out, indent)
|
|
85
|
+
out << ""
|
|
86
|
+
end
|
|
87
|
+
end
|
|
124
88
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
89
|
+
# In the shared workflow the types live once in the shared module and each
|
|
90
|
+
# query module aliases what it uses, so AdoptMutation::AdoptionInput stays a
|
|
91
|
+
# real constant — and a shared type keeps ONE identity across every module
|
|
92
|
+
# that touches it.
|
|
93
|
+
#
|
|
94
|
+
# Enums: every one this walk reached, since a result field and a variable
|
|
95
|
+
# both reference it by that name.
|
|
96
|
+
def shared_enum_names
|
|
97
|
+
@mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] } +
|
|
98
|
+
@enums.each_value.map(&:class_name)
|
|
99
|
+
end
|
|
133
100
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
101
|
+
# Inputs: only the variable root types — the names this module's own
|
|
102
|
+
# source spells. Nested input types stay un-aliased; they live in the
|
|
103
|
+
# shared module.
|
|
104
|
+
def shared_input_names(variables)
|
|
105
|
+
variables.map(&:node).filter_map { |wrapped|
|
|
106
|
+
node = T.let(wrapped, T.untyped)
|
|
107
|
+
node = node.of while node.is_a?(NonNull) || node.is_a?(List)
|
|
108
|
+
node.class_name if node.is_a?(InputNode)
|
|
109
|
+
}.uniq
|
|
110
|
+
end
|
|
144
111
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
112
|
+
def emit_shared_aliases(out, names, namespace)
|
|
113
|
+
return if names.empty?
|
|
173
114
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
115
|
+
names.each { |name| out << " #{name} = #{namespace}::#{name}" }
|
|
116
|
+
out << ""
|
|
117
|
+
end
|
|
177
118
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
119
|
+
# The shared types artifact as files: one file per type under types/, plus
|
|
120
|
+
# types.rb — the manifest that requires them in the order the runtime needs
|
|
121
|
+
# (see below). One rule for all three kinds, so a schema migration diffs
|
|
122
|
+
# exactly the types it touched whether they're inputs, enums or unions.
|
|
123
|
+
def emit_types_files(unions)
|
|
124
|
+
files = {}
|
|
125
|
+
# a mapped enum's constants are its wire tables, but the file is still
|
|
126
|
+
# named for the GraphQL enum — one type, one file, whichever it is
|
|
127
|
+
enums = @mapped_enums.map { |name, mapped|
|
|
128
|
+
type_file(files, camelize(name)) { |out| emit_mapped_enum(mapped, out, 1) }
|
|
129
|
+
} + @enums.each_value.map { |enum|
|
|
130
|
+
type_file(files, enum.class_name) { |out| emit_enum(enum, out, 1) }
|
|
131
|
+
}
|
|
132
|
+
inputs, = ordered_inputs
|
|
133
|
+
structs = inputs.map { |input| type_file(files, input.class_name) { |out| emit_input(input, out, 1) } }
|
|
134
|
+
hoisted = unions.map { |union| type_file(files, union.class_name) { |out| emit_union(union, out, 1) } }
|
|
135
|
+
|
|
136
|
+
out = []
|
|
137
|
+
out << "# typed: strict"
|
|
138
|
+
out << "# frozen_string_literal: true"
|
|
139
|
+
out << ""
|
|
140
|
+
out << "# Generated by GraphWeaver — do not edit. Shared types for this schema —"
|
|
141
|
+
out << "# input types, enums, and unions hoisted from shared fragments — one file"
|
|
142
|
+
out << "# per type; query modules alias what they use."
|
|
143
|
+
out << ""
|
|
144
|
+
requires = @requires.uniq.sort
|
|
145
|
+
if requires.any?
|
|
146
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
189
147
|
out << ""
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
148
|
+
end
|
|
149
|
+
out << "module #{@module_name}; end"
|
|
150
|
+
out << ""
|
|
151
|
+
if inputs.any?
|
|
152
|
+
out << "# runtime-only forward declarations: input types reference each other"
|
|
153
|
+
out << "# across files, so every constant must exist before any definition loads"
|
|
154
|
+
out << "# (srb sees only the full bodies)"
|
|
195
155
|
out << "module #{@module_name}"
|
|
196
|
-
out << "
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
out << ""
|
|
200
|
-
end
|
|
201
|
-
out.pop if out.last == ""
|
|
156
|
+
out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
|
|
157
|
+
inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
|
|
158
|
+
out << " RUBY"
|
|
202
159
|
out << "end"
|
|
203
|
-
|
|
204
|
-
|
|
160
|
+
out << ""
|
|
161
|
+
end
|
|
162
|
+
# enums first: an input struct's props and a union member's selections
|
|
163
|
+
# both spell them bare, and a T::Enum can't be forward-declared the way
|
|
164
|
+
# an input struct can
|
|
165
|
+
if enums.any? && (structs.any? || hoisted.any?)
|
|
166
|
+
out << "# enums first — input structs and union members spell them bare"
|
|
167
|
+
end
|
|
168
|
+
(enums.sort + structs.sort + hoisted.sort).each do |file|
|
|
169
|
+
out << "require_relative #{file.delete_suffix(".rb").inspect}"
|
|
205
170
|
end
|
|
206
171
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
out = []
|
|
211
|
-
out << "# typed: strict"
|
|
212
|
-
out << "# frozen_string_literal: true"
|
|
213
|
-
out << ""
|
|
214
|
-
out << "# Generated by GraphWeaver — do not edit."
|
|
215
|
-
out << ""
|
|
216
|
-
out << "module #{@module_name}"
|
|
217
|
-
yield(out)
|
|
218
|
-
out << "end"
|
|
172
|
+
files["types.rb"] = out.join("\n") + "\n"
|
|
173
|
+
files
|
|
174
|
+
end
|
|
219
175
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
176
|
+
# one type per file, wrapped in the shared module so bare sibling
|
|
177
|
+
# references resolve lexically
|
|
178
|
+
def type_file(files, name)
|
|
179
|
+
out = []
|
|
180
|
+
out << "# typed: strict"
|
|
181
|
+
out << "# frozen_string_literal: true"
|
|
182
|
+
out << ""
|
|
183
|
+
out << "# Generated by GraphWeaver — do not edit."
|
|
184
|
+
out << ""
|
|
185
|
+
out << "module #{@module_name}"
|
|
186
|
+
yield(out)
|
|
187
|
+
out << "end"
|
|
224
188
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
189
|
+
file = "types/#{GraphWeaver::Inflect.underscore(name)}.rb"
|
|
190
|
+
files[file] = out.join("\n") + "\n"
|
|
191
|
+
file
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The whole generated file: header, requires, the QUERY heredoc and
|
|
195
|
+
# its OPERATION_NAME, enum tables, input structs (dependency-ordered,
|
|
196
|
+
# forward-declared when cyclic), the Result tree, and execute —
|
|
197
|
+
# assembled from the generator's walked state.
|
|
198
|
+
def emit_module(root, variables, representations = [], operation_name = nil)
|
|
199
|
+
# Every shared type this module names: its variable root inputs, the
|
|
200
|
+
# enums it reached, and the unions it hoisted (aliased so <Name>::Type
|
|
201
|
+
# and <Name>.from_h resolve to the shared module).
|
|
202
|
+
aliases = if @types_namespace
|
|
203
|
+
(shared_input_names(variables) + shared_enum_names +
|
|
204
|
+
@used_unions.map { |name| camelize(name) }).uniq.sort
|
|
205
|
+
else
|
|
206
|
+
[]
|
|
207
|
+
end
|
|
235
208
|
|
|
236
209
|
out = []
|
|
237
210
|
out << "# typed: strict"
|
|
@@ -244,14 +217,10 @@ class GraphWeaver::Codegen
|
|
|
244
217
|
requires.each { |req| out << "require #{req.inspect}" }
|
|
245
218
|
out << ""
|
|
246
219
|
end
|
|
220
|
+
# the aliases below need the shared module loaded (same directory by the
|
|
221
|
+
# generate! convention)
|
|
247
222
|
if aliases.any?
|
|
248
|
-
|
|
249
|
-
# by the generate! convention)
|
|
250
|
-
out << "require_relative \"inputs\""
|
|
251
|
-
out << ""
|
|
252
|
-
end
|
|
253
|
-
if union_aliases.any?
|
|
254
|
-
out << "require_relative \"unions\""
|
|
223
|
+
out << "require_relative \"types\""
|
|
255
224
|
out << ""
|
|
256
225
|
end
|
|
257
226
|
out << "module #{@module_name}"
|
|
@@ -265,20 +234,71 @@ class GraphWeaver::Codegen
|
|
|
265
234
|
@query.each_line { |line| out << " #{line}".rstrip }
|
|
266
235
|
out << " #{delimiter}"
|
|
267
236
|
out << ""
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
237
|
+
out << " # sent as the request's operationName — what an APM keys traces on"
|
|
238
|
+
out << " OPERATION_NAME = T.let(#{operation_name.inspect}, T.nilable(String))"
|
|
239
|
+
out << ""
|
|
240
|
+
emit_shared_aliases(out, aliases, @types_namespace)
|
|
241
|
+
emit_variable_types(out)
|
|
242
|
+
emit_representations(out, representations)
|
|
274
243
|
emit_nested(root, out, 1)
|
|
275
244
|
out << ""
|
|
276
|
-
emit_execute(out, variables
|
|
245
|
+
emit_execute(out, variables)
|
|
277
246
|
out << "end"
|
|
278
247
|
|
|
279
248
|
out.join("\n") + "\n"
|
|
280
249
|
end
|
|
281
250
|
|
|
251
|
+
# Typed constructors for the entity references an
|
|
252
|
+
# `_entities(representations:)` query takes — one per entity the query's
|
|
253
|
+
# selection reaches, its kwargs the type's @key fields. A single-key
|
|
254
|
+
# entity types them required, so an incomplete representation is a
|
|
255
|
+
# Sorbet error rather than a round trip; alternative keys and nested key
|
|
256
|
+
# sets are what GraphWeaver::Representation.build checks at runtime.
|
|
257
|
+
def emit_representations(out, nodes)
|
|
258
|
+
return if nodes.empty?
|
|
259
|
+
|
|
260
|
+
out << " # Entity references for _entities(representations:) — one builder"
|
|
261
|
+
out << " # per entity this query can resolve, typed from its @key fields."
|
|
262
|
+
out << " module Representations"
|
|
263
|
+
out << " extend T::Sig" if GraphWeaver.extend_t_sig?
|
|
264
|
+
|
|
265
|
+
nodes.each do |node|
|
|
266
|
+
out << ""
|
|
267
|
+
out << " # #{node.graphql_type} #{node.key_fields.map { |set| "@key(fields: #{set.inspect})" }.join(" ")}"
|
|
268
|
+
sig = node.params.map { |param| "#{param.kwarg}: #{param.type}" }.join(", ")
|
|
269
|
+
out << " sig { params(#{sig}).returns(T::Hash[String, T.untyped]) }"
|
|
270
|
+
kwargs = node.params.map { |param| param.required ? "#{param.kwarg}:" : "#{param.kwarg}: nil" }.join(", ")
|
|
271
|
+
out << " def self.#{node.method_name}(#{kwargs})"
|
|
272
|
+
out << " GraphWeaver::Representation.build(#{node.graphql_type.inspect}, {"
|
|
273
|
+
node.params.each { |param| out << " #{param.wire.inspect} => #{param.value}," }
|
|
274
|
+
out << " }, #{node.key_sets.inspect})"
|
|
275
|
+
out << " end"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Builders are query-driven, so an entity the `_entities` selection
|
|
279
|
+
# doesn't name has none — a bare NoMethodError there points at nothing.
|
|
280
|
+
out << ""
|
|
281
|
+
out << " BUILDERS = T.let(#{nodes.map(&:method_name).sort.inspect}.freeze, T::Array[String])"
|
|
282
|
+
out << " private_constant :BUILDERS"
|
|
283
|
+
out << ""
|
|
284
|
+
out << " sig { params(name: Symbol, args: T.untyped, block: T.untyped).returns(T.noreturn) }"
|
|
285
|
+
out << " def self.method_missing(name, *args, &block)"
|
|
286
|
+
out << ' type = GraphWeaver::Inflect.camelize(name.to_s)'
|
|
287
|
+
out << ' raise NoMethodError, "no representation builder for #{type} (this query builds: ' \
|
|
288
|
+
'#{BUILDERS.join(", ")}) — if #{type} is an entity of this subgraph, name it in the ' \
|
|
289
|
+
'_entities selection (`... on #{type} { __typename }`) and regenerate"'
|
|
290
|
+
out << " end"
|
|
291
|
+
|
|
292
|
+
out << " end"
|
|
293
|
+
out << ""
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Is this node defined once at module level rather than inside the struct
|
|
297
|
+
# that references it? (Variable enums — see the ENUM branch of object_node.)
|
|
298
|
+
def module_level?(node)
|
|
299
|
+
@enums.value?(node)
|
|
300
|
+
end
|
|
301
|
+
|
|
282
302
|
def emit_nested(node, out, indent)
|
|
283
303
|
case node
|
|
284
304
|
when UnionNode then emit_union(node, out, indent)
|
|
@@ -327,7 +347,11 @@ class GraphWeaver::Codegen
|
|
|
327
347
|
|
|
328
348
|
# uniq by object identity: deduped sibling unions share one node, so the
|
|
329
349
|
# shared type is emitted once (both fields' consts already reference it).
|
|
330
|
-
|
|
350
|
+
# A variable enum a result field reuses is already defined at module
|
|
351
|
+
# level (or aliased from the shared inputs module) — redefining it here
|
|
352
|
+
# would shadow the shared type back apart.
|
|
353
|
+
children = node.fields.filter_map { |field| field.node.nested }.uniq
|
|
354
|
+
children.reject { |child| module_level?(child) }.each do |child|
|
|
331
355
|
emit_nested(child, out, indent + 1)
|
|
332
356
|
out << ""
|
|
333
357
|
end
|
|
@@ -344,9 +368,9 @@ class GraphWeaver::Codegen
|
|
|
344
368
|
out << "#{pad} #{field.prop}: #{field_cast(field)},"
|
|
345
369
|
end
|
|
346
370
|
out << "#{pad} )"
|
|
347
|
-
out << "#{pad} rescue GraphWeaver::
|
|
348
|
-
out << "#{pad} raise # already
|
|
349
|
-
out << "#{pad} rescue
|
|
371
|
+
out << "#{pad} rescue GraphWeaver::Error"
|
|
372
|
+
out << "#{pad} raise # already branded by a nested struct — keep the innermost context"
|
|
373
|
+
out << "#{pad} rescue StandardError => e" # a scalar's cast may raise anything
|
|
350
374
|
out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
|
|
351
375
|
out << "#{pad} end"
|
|
352
376
|
|
|
@@ -366,95 +390,93 @@ class GraphWeaver::Codegen
|
|
|
366
390
|
out << "#{pad}module #{node.class_name}"
|
|
367
391
|
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
368
392
|
|
|
369
|
-
node.members.
|
|
393
|
+
structs = node.members.values + [node.catch_all].compact
|
|
394
|
+
structs.each do |member|
|
|
370
395
|
emit_object(member, out, indent + 1)
|
|
371
396
|
out << ""
|
|
372
397
|
end
|
|
373
398
|
|
|
374
|
-
member_names =
|
|
399
|
+
member_names = structs.map(&:class_name)
|
|
375
400
|
type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
|
|
376
401
|
out << "#{pad} Type = T.type_alias { #{type_alias} }"
|
|
377
402
|
out << ""
|
|
378
403
|
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
379
404
|
out << "#{pad} def self.from_h(data)"
|
|
380
|
-
|
|
405
|
+
if node.catch_all
|
|
406
|
+
out << "#{pad} case data.fetch(\"__typename\")"
|
|
407
|
+
else
|
|
408
|
+
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
409
|
+
end
|
|
381
410
|
node.members.each do |graphql_name, member|
|
|
382
411
|
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
383
412
|
end
|
|
384
|
-
|
|
413
|
+
if node.catch_all
|
|
414
|
+
out << "#{pad} # a member this query names no fields on — including one the"
|
|
415
|
+
out << "#{pad} # schema grew since generation"
|
|
416
|
+
out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
|
|
417
|
+
else
|
|
418
|
+
out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
|
|
419
|
+
end
|
|
385
420
|
out << "#{pad} end"
|
|
386
421
|
out << "#{pad} end"
|
|
387
422
|
out << "#{pad}end"
|
|
388
423
|
end
|
|
389
424
|
|
|
390
|
-
def emit_execute(out, variables
|
|
391
|
-
|
|
392
|
-
out << ""
|
|
393
|
-
out << "
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
out << " # execute: per-module override, else the app default"
|
|
400
|
-
out << " sig { returns(T.untyped) }"
|
|
401
|
-
out << " def client"
|
|
402
|
-
out << " @client || #{@client_const || "GraphWeaver.client!"}"
|
|
403
|
-
out << " end"
|
|
404
|
-
out << " end"
|
|
425
|
+
def emit_execute(out, variables)
|
|
426
|
+
# client/client= carry no per-query types, so they live in the gem
|
|
427
|
+
out << " # client / client= — see GraphWeaver::QueryModule"
|
|
428
|
+
out << " extend GraphWeaver::QueryModule"
|
|
429
|
+
if @client_const
|
|
430
|
+
out << ""
|
|
431
|
+
out << " # the baked default client, resolved on first use"
|
|
432
|
+
out << " DEFAULT_CLIENT = T.let(-> { #{@client_const} }, T.proc.returns(T.untyped))"
|
|
433
|
+
end
|
|
405
434
|
out << ""
|
|
406
435
|
|
|
407
|
-
#
|
|
408
|
-
#
|
|
409
|
-
# per-call client override
|
|
410
|
-
#
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
436
|
+
# The kwarg surface: one kwarg per declared variable, always — so the
|
|
437
|
+
# call sites a query already has don't change shape when it grows one.
|
|
438
|
+
# The per-call client override is a kwarg like the rest; a GraphQL
|
|
439
|
+
# variable can't claim the name (RESERVED_KWARGS).
|
|
440
|
+
#
|
|
441
|
+
# Required kwargs first, then optional ones (client: last, since it
|
|
442
|
+
# always has a default): Method#parameters reports them in that order
|
|
443
|
+
# whatever the source says, and sorbet-runtime checks the sig against it.
|
|
444
|
+
required, optional = variables.partition(&:required)
|
|
445
|
+
ordered = required + optional
|
|
446
|
+
|
|
447
|
+
sig_params = ordered.map do |var|
|
|
448
|
+
bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
|
|
449
|
+
kwarg_type = var.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
|
|
450
|
+
"#{var.kwarg}: #{kwarg_type}"
|
|
418
451
|
end
|
|
452
|
+
sig_params << "client: T.untyped"
|
|
419
453
|
|
|
420
|
-
kwargs =
|
|
421
|
-
|
|
454
|
+
kwargs = required.map { |var| "#{var.kwarg}:" } +
|
|
455
|
+
optional.map { |var| "#{var.kwarg}: nil" } + ["client: nil"]
|
|
422
456
|
|
|
423
457
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
424
458
|
# `execute(...).data!` — the typed result, or a raised QueryError.
|
|
425
459
|
# kwargs forward via hash shorthand (key == value)
|
|
426
|
-
forward = (
|
|
460
|
+
forward = (ordered.map { |var| "#{var.kwarg}:" } + ["client:"]).join(", ")
|
|
427
461
|
|
|
428
|
-
if flatten
|
|
429
|
-
out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
|
|
430
|
-
end
|
|
431
462
|
out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
|
|
432
463
|
out << " def self.execute(#{kwargs.join(", ")})"
|
|
433
464
|
|
|
434
|
-
if
|
|
435
|
-
|
|
436
|
-
out << " variables = {"
|
|
437
|
-
out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
|
|
438
|
-
out << " }"
|
|
465
|
+
if required.empty?
|
|
466
|
+
out << " variables = {}"
|
|
439
467
|
else
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
out << "
|
|
443
|
-
else
|
|
444
|
-
out << " variables = {"
|
|
445
|
-
required.each do |var|
|
|
446
|
-
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
447
|
-
end
|
|
448
|
-
out << " }"
|
|
449
|
-
end
|
|
450
|
-
optional.each do |var|
|
|
451
|
-
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
468
|
+
out << " variables = {"
|
|
469
|
+
required.each do |var|
|
|
470
|
+
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
452
471
|
end
|
|
472
|
+
out << " }"
|
|
473
|
+
end
|
|
474
|
+
optional.each do |var|
|
|
475
|
+
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
453
476
|
end
|
|
454
477
|
|
|
455
478
|
out << ""
|
|
456
|
-
out << "
|
|
457
|
-
out << " from_response(transport.execute(QUERY, variables: variables))"
|
|
479
|
+
out << " from_response(client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME))"
|
|
458
480
|
out << " end"
|
|
459
481
|
out << ""
|
|
460
482
|
out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
|
|
@@ -477,7 +499,7 @@ class GraphWeaver::Codegen
|
|
|
477
499
|
out << " # \"errors\" => ..., \"extensions\" => ...} with wire-cased string keys."
|
|
478
500
|
out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
|
|
479
501
|
out << " def self.from_response(response)"
|
|
480
|
-
out << " raw = response.to_h"
|
|
502
|
+
out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
|
|
481
503
|
out << " GraphWeaver::Response[Result].new("
|
|
482
504
|
out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
|
|
483
505
|
out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
|
|
@@ -492,12 +514,6 @@ class GraphWeaver::Codegen
|
|
|
492
514
|
out << " end"
|
|
493
515
|
end
|
|
494
516
|
|
|
495
|
-
# a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
|
|
496
|
-
# InputNode::Field (.prop)
|
|
497
|
-
def kwarg_name(param)
|
|
498
|
-
param.respond_to?(:kwarg) ? param.kwarg : param.prop
|
|
499
|
-
end
|
|
500
|
-
|
|
501
517
|
def variable_serialize(var)
|
|
502
518
|
value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
|
|
503
519
|
var.node.serialize_identity? ? value : var.node.serialize(value, 1)
|
|
@@ -526,6 +542,11 @@ class GraphWeaver::Codegen
|
|
|
526
542
|
out << "#{pad} include GraphWeaver::InputStruct"
|
|
527
543
|
out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
|
|
528
544
|
out << ""
|
|
545
|
+
if node.one_of
|
|
546
|
+
out << "#{pad} # @oneOf: every field is nullable, so exactly-one is checked at runtime"
|
|
547
|
+
out << "#{pad} ONE_OF = T.let(true, T::Boolean)"
|
|
548
|
+
out << ""
|
|
549
|
+
end
|
|
529
550
|
node.fields.each do |field|
|
|
530
551
|
default = field.required ? "" : ", default: nil"
|
|
531
552
|
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
|