graph_weaver 0.4.6 → 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 +1314 -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 -154
- 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 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- 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 +181 -21
- 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 +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -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,185 +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
|
-
# same way emit_variable_types does for the query module
|
|
200
|
-
@mapped_enums.each_value do |mapped|
|
|
201
|
-
emit_mapped_enum(mapped, out, 1)
|
|
202
|
-
out << ""
|
|
203
|
-
end
|
|
204
|
-
unions.each do |union|
|
|
205
|
-
emit_union(union, out, 1)
|
|
206
|
-
out << ""
|
|
207
|
-
end
|
|
208
|
-
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"
|
|
209
159
|
out << "end"
|
|
210
|
-
|
|
211
|
-
|
|
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}"
|
|
212
170
|
end
|
|
213
171
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
out = []
|
|
218
|
-
out << "# typed: strict"
|
|
219
|
-
out << "# frozen_string_literal: true"
|
|
220
|
-
out << ""
|
|
221
|
-
out << "# Generated by GraphWeaver — do not edit."
|
|
222
|
-
out << ""
|
|
223
|
-
out << "module #{@module_name}"
|
|
224
|
-
yield(out)
|
|
225
|
-
out << "end"
|
|
172
|
+
files["types.rb"] = out.join("\n") + "\n"
|
|
173
|
+
files
|
|
174
|
+
end
|
|
226
175
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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"
|
|
231
188
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
208
|
|
|
243
209
|
out = []
|
|
244
210
|
out << "# typed: strict"
|
|
@@ -251,14 +217,10 @@ class GraphWeaver::Codegen
|
|
|
251
217
|
requires.each { |req| out << "require #{req.inspect}" }
|
|
252
218
|
out << ""
|
|
253
219
|
end
|
|
220
|
+
# the aliases below need the shared module loaded (same directory by the
|
|
221
|
+
# generate! convention)
|
|
254
222
|
if aliases.any?
|
|
255
|
-
|
|
256
|
-
# by the generate! convention)
|
|
257
|
-
out << "require_relative \"inputs\""
|
|
258
|
-
out << ""
|
|
259
|
-
end
|
|
260
|
-
if union_aliases.any?
|
|
261
|
-
out << "require_relative \"unions\""
|
|
223
|
+
out << "require_relative \"types\""
|
|
262
224
|
out << ""
|
|
263
225
|
end
|
|
264
226
|
out << "module #{@module_name}"
|
|
@@ -272,20 +234,71 @@ class GraphWeaver::Codegen
|
|
|
272
234
|
@query.each_line { |line| out << " #{line}".rstrip }
|
|
273
235
|
out << " #{delimiter}"
|
|
274
236
|
out << ""
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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)
|
|
281
243
|
emit_nested(root, out, 1)
|
|
282
244
|
out << ""
|
|
283
|
-
emit_execute(out, variables
|
|
245
|
+
emit_execute(out, variables)
|
|
284
246
|
out << "end"
|
|
285
247
|
|
|
286
248
|
out.join("\n") + "\n"
|
|
287
249
|
end
|
|
288
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
|
+
|
|
289
302
|
def emit_nested(node, out, indent)
|
|
290
303
|
case node
|
|
291
304
|
when UnionNode then emit_union(node, out, indent)
|
|
@@ -334,7 +347,11 @@ class GraphWeaver::Codegen
|
|
|
334
347
|
|
|
335
348
|
# uniq by object identity: deduped sibling unions share one node, so the
|
|
336
349
|
# shared type is emitted once (both fields' consts already reference it).
|
|
337
|
-
|
|
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|
|
|
338
355
|
emit_nested(child, out, indent + 1)
|
|
339
356
|
out << ""
|
|
340
357
|
end
|
|
@@ -351,9 +368,9 @@ class GraphWeaver::Codegen
|
|
|
351
368
|
out << "#{pad} #{field.prop}: #{field_cast(field)},"
|
|
352
369
|
end
|
|
353
370
|
out << "#{pad} )"
|
|
354
|
-
out << "#{pad} rescue GraphWeaver::
|
|
355
|
-
out << "#{pad} raise # already
|
|
356
|
-
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
|
|
357
374
|
out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
|
|
358
375
|
out << "#{pad} end"
|
|
359
376
|
|
|
@@ -373,95 +390,93 @@ class GraphWeaver::Codegen
|
|
|
373
390
|
out << "#{pad}module #{node.class_name}"
|
|
374
391
|
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
375
392
|
|
|
376
|
-
node.members.
|
|
393
|
+
structs = node.members.values + [node.catch_all].compact
|
|
394
|
+
structs.each do |member|
|
|
377
395
|
emit_object(member, out, indent + 1)
|
|
378
396
|
out << ""
|
|
379
397
|
end
|
|
380
398
|
|
|
381
|
-
member_names =
|
|
399
|
+
member_names = structs.map(&:class_name)
|
|
382
400
|
type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
|
|
383
401
|
out << "#{pad} Type = T.type_alias { #{type_alias} }"
|
|
384
402
|
out << ""
|
|
385
403
|
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
386
404
|
out << "#{pad} def self.from_h(data)"
|
|
387
|
-
|
|
405
|
+
if node.catch_all
|
|
406
|
+
out << "#{pad} case data.fetch(\"__typename\")"
|
|
407
|
+
else
|
|
408
|
+
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
409
|
+
end
|
|
388
410
|
node.members.each do |graphql_name, member|
|
|
389
411
|
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
390
412
|
end
|
|
391
|
-
|
|
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
|
|
392
420
|
out << "#{pad} end"
|
|
393
421
|
out << "#{pad} end"
|
|
394
422
|
out << "#{pad}end"
|
|
395
423
|
end
|
|
396
424
|
|
|
397
|
-
def emit_execute(out, variables
|
|
398
|
-
|
|
399
|
-
out << ""
|
|
400
|
-
out << "
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
out << " # execute: per-module override, else the app default"
|
|
407
|
-
out << " sig { returns(T.untyped) }"
|
|
408
|
-
out << " def client"
|
|
409
|
-
out << " @client || #{@client_const || "GraphWeaver.client!"}"
|
|
410
|
-
out << " end"
|
|
411
|
-
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
|
|
412
434
|
out << ""
|
|
413
435
|
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
# per-call client override
|
|
417
|
-
#
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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}"
|
|
425
451
|
end
|
|
452
|
+
sig_params << "client: T.untyped"
|
|
426
453
|
|
|
427
|
-
kwargs =
|
|
428
|
-
|
|
454
|
+
kwargs = required.map { |var| "#{var.kwarg}:" } +
|
|
455
|
+
optional.map { |var| "#{var.kwarg}: nil" } + ["client: nil"]
|
|
429
456
|
|
|
430
457
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
431
458
|
# `execute(...).data!` — the typed result, or a raised QueryError.
|
|
432
459
|
# kwargs forward via hash shorthand (key == value)
|
|
433
|
-
forward = (
|
|
460
|
+
forward = (ordered.map { |var| "#{var.kwarg}:" } + ["client:"]).join(", ")
|
|
434
461
|
|
|
435
|
-
if flatten
|
|
436
|
-
out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
|
|
437
|
-
end
|
|
438
462
|
out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
|
|
439
463
|
out << " def self.execute(#{kwargs.join(", ")})"
|
|
440
464
|
|
|
441
|
-
if
|
|
442
|
-
|
|
443
|
-
out << " variables = {"
|
|
444
|
-
out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
|
|
445
|
-
out << " }"
|
|
465
|
+
if required.empty?
|
|
466
|
+
out << " variables = {}"
|
|
446
467
|
else
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
out << "
|
|
450
|
-
else
|
|
451
|
-
out << " variables = {"
|
|
452
|
-
required.each do |var|
|
|
453
|
-
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
454
|
-
end
|
|
455
|
-
out << " }"
|
|
456
|
-
end
|
|
457
|
-
optional.each do |var|
|
|
458
|
-
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)},"
|
|
459
471
|
end
|
|
472
|
+
out << " }"
|
|
473
|
+
end
|
|
474
|
+
optional.each do |var|
|
|
475
|
+
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
460
476
|
end
|
|
461
477
|
|
|
462
478
|
out << ""
|
|
463
|
-
out << "
|
|
464
|
-
out << " from_response(transport.execute(QUERY, variables: variables))"
|
|
479
|
+
out << " from_response(client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME))"
|
|
465
480
|
out << " end"
|
|
466
481
|
out << ""
|
|
467
482
|
out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
|
|
@@ -484,7 +499,7 @@ class GraphWeaver::Codegen
|
|
|
484
499
|
out << " # \"errors\" => ..., \"extensions\" => ...} with wire-cased string keys."
|
|
485
500
|
out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
|
|
486
501
|
out << " def self.from_response(response)"
|
|
487
|
-
out << " raw = response.to_h"
|
|
502
|
+
out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
|
|
488
503
|
out << " GraphWeaver::Response[Result].new("
|
|
489
504
|
out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
|
|
490
505
|
out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
|
|
@@ -499,12 +514,6 @@ class GraphWeaver::Codegen
|
|
|
499
514
|
out << " end"
|
|
500
515
|
end
|
|
501
516
|
|
|
502
|
-
# a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
|
|
503
|
-
# InputNode::Field (.prop)
|
|
504
|
-
def kwarg_name(param)
|
|
505
|
-
param.respond_to?(:kwarg) ? param.kwarg : param.prop
|
|
506
|
-
end
|
|
507
|
-
|
|
508
517
|
def variable_serialize(var)
|
|
509
518
|
value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
|
|
510
519
|
var.node.serialize_identity? ? value : var.node.serialize(value, 1)
|
|
@@ -533,6 +542,11 @@ class GraphWeaver::Codegen
|
|
|
533
542
|
out << "#{pad} include GraphWeaver::InputStruct"
|
|
534
543
|
out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
|
|
535
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
|
|
536
550
|
node.fields.each do |field|
|
|
537
551
|
default = field.required ? "" : ", default: nil"
|
|
538
552
|
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
|