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
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -16,22 +16,31 @@ require "sorbet-runtime"
|
|
|
16
16
|
# still open.
|
|
17
17
|
#
|
|
18
18
|
# Split across: codegen/scalar_type.rb (the scalar registry),
|
|
19
|
-
# codegen/nodes.rb (the typed IR), codegen/
|
|
20
|
-
# this file holds the public
|
|
19
|
+
# codegen/nodes.rb (the typed IR), codegen/aliases.rb (registered alias
|
|
20
|
+
# paths), codegen/emit.rb (source emission); this file holds the public
|
|
21
|
+
# API and the query walk.
|
|
21
22
|
require_relative "hints"
|
|
22
23
|
require_relative "input_struct"
|
|
24
|
+
require_relative "representation"
|
|
23
25
|
require_relative "inflect"
|
|
24
26
|
require_relative "selection"
|
|
25
27
|
require_relative "codegen/enum_type"
|
|
26
28
|
require_relative "codegen/scalar_type"
|
|
27
29
|
require_relative "codegen/nodes"
|
|
30
|
+
require_relative "codegen/aliases"
|
|
28
31
|
require_relative "codegen/emit"
|
|
29
32
|
|
|
30
33
|
class GraphWeaver::Codegen
|
|
31
34
|
include GraphWeaver::Inflect
|
|
32
35
|
include GraphWeaver::Selection
|
|
36
|
+
include Aliases
|
|
33
37
|
include Emit
|
|
34
38
|
|
|
39
|
+
# How a directory of GraphQL documents is scanned: both extensions the rest of
|
|
40
|
+
# the library already accepts, and nested — `queries/admin/pets.graphql` is
|
|
41
|
+
# how anyone with sixty queries organizes them.
|
|
42
|
+
DOCUMENT_GLOB = "**/*.{graphql,gql}"
|
|
43
|
+
|
|
35
44
|
attr_reader :module_name
|
|
36
45
|
|
|
37
46
|
# A client is anything responding to `execute(query, variables:)`
|
|
@@ -44,31 +53,26 @@ class GraphWeaver::Codegen
|
|
|
44
53
|
# defaults to the operation's
|
|
45
54
|
# name; default_module_name: is parse's container-scoped fallback (file
|
|
46
55
|
# generation stays strict — a checked-in file deserves a deliberate
|
|
47
|
-
# name).
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# shared
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
56
|
+
# name). types_namespace: is the shared-types workflow (see
|
|
57
|
+
# GraphWeaver.generate!): input types, schema enums, and unions hoisted from
|
|
58
|
+
# shared fragments live once in that module and the query module aliases what
|
|
59
|
+
# it uses. hoistable_unions: is the set of shared fragment names this query
|
|
60
|
+
# may hoist (spreads it inlined, minus any it shadows locally) — a
|
|
61
|
+
# whole-union field spread as one of them resolves to a canonical type in the
|
|
62
|
+
# shared module (see used_union_names). path: is the file the query was read
|
|
63
|
+
# from, named alongside line and column in validation errors.
|
|
55
64
|
def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
|
|
56
|
-
|
|
57
|
-
hoistable_unions: nil)
|
|
65
|
+
types_namespace: nil, hoistable_unions: nil, path: nil)
|
|
58
66
|
@schema = schema
|
|
59
67
|
@query = query.strip
|
|
68
|
+
@path = path
|
|
60
69
|
@module_name = module_name
|
|
61
70
|
@default_module_name = default_module_name
|
|
62
|
-
@
|
|
63
|
-
@enums = enums || {}
|
|
64
|
-
@types = types || {}
|
|
65
|
-
@inputs_namespace = inputs_namespace
|
|
66
|
-
# the shared-unions workflow: unions_namespace names the module hoisted
|
|
67
|
-
# unions live in; hoistable_unions is the set of shared fragment names this
|
|
68
|
-
# query may hoist (spreads it inlined, minus any it shadows locally)
|
|
69
|
-
@unions_namespace = unions_namespace
|
|
71
|
+
@types_namespace = types_namespace
|
|
70
72
|
@hoistable_unions = hoistable_unions || []
|
|
71
73
|
@used_unions = []
|
|
74
|
+
# scalars this generation had no registration for (see report_untyped_scalars)
|
|
75
|
+
@untyped_scalars = []
|
|
72
76
|
@client_const = self.class.client_const(client)
|
|
73
77
|
|
|
74
78
|
if client && @client_const.nil?
|
|
@@ -88,8 +92,8 @@ class GraphWeaver::Codegen
|
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
# one-step shorthand
|
|
91
|
-
def self.generate(schema:, query:, module_name: nil, client: nil,
|
|
92
|
-
new(schema:, query:, module_name:, client:,
|
|
95
|
+
def self.generate(schema:, query:, module_name: nil, client: nil, path: nil)
|
|
96
|
+
new(schema:, query:, module_name:, client:, path:).generate
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
# Development convenience: generate + eval in one step, no build
|
|
@@ -97,11 +101,11 @@ class GraphWeaver::Codegen
|
|
|
97
101
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
98
102
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
99
103
|
# client: additionally accepts a live object (set via .client=).
|
|
100
|
-
def self.parse(schema:, query:, module_name: nil, client: nil,
|
|
104
|
+
def self.parse(schema:, query:, module_name: nil, client: nil, path: nil)
|
|
101
105
|
client_const = client_const(client)
|
|
102
106
|
|
|
103
|
-
codegen = new(schema:, query:, module_name:, client: client_const,
|
|
104
|
-
|
|
107
|
+
codegen = new(schema:, query:, module_name:, client: client_const, path:,
|
|
108
|
+
default_module_name: "Query")
|
|
105
109
|
source = codegen.generate
|
|
106
110
|
|
|
107
111
|
container = Module.new
|
|
@@ -114,92 +118,141 @@ class GraphWeaver::Codegen
|
|
|
114
118
|
mod
|
|
115
119
|
end
|
|
116
120
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
121
|
+
# Every registry back to its starting state — scalars (built-ins restored),
|
|
122
|
+
# enum mappings, and type helpers. The clean slate between tests, and the
|
|
123
|
+
# one call that stays right when a fourth kind of registration shows up.
|
|
124
|
+
def self.reset_registrations!
|
|
125
|
+
reset_scalars!
|
|
126
|
+
reset_enums!
|
|
127
|
+
reset_type_helpers!
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The schema-level types this walk touched, by GraphQL name — the generate!
|
|
132
|
+
# workflow unions these across queries to decide what the shared types module
|
|
133
|
+
# must contain.
|
|
120
134
|
def variable_type_names
|
|
121
|
-
{ inputs: @variable_inputs.keys, enums: @
|
|
135
|
+
{ inputs: @variable_inputs.keys, enums: @enums.keys, mapped: @mapped_enums.keys }
|
|
122
136
|
end
|
|
123
137
|
|
|
124
138
|
# The shared union fragments this query hoisted, by name — the generate!
|
|
125
|
-
# workflow unions these across queries to decide what the shared
|
|
126
|
-
#
|
|
139
|
+
# workflow unions these across queries to decide what the shared types module
|
|
140
|
+
# must contain.
|
|
127
141
|
def used_union_names = @used_unions.dup
|
|
128
142
|
|
|
129
|
-
# The shared
|
|
130
|
-
#
|
|
131
|
-
# a
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
# The shared types artifact: every type a schema shares across query modules,
|
|
144
|
+
# emitted once as a manifest (types.rb) plus one file per type under types/,
|
|
145
|
+
# so a schema migration diffs only the types it touched. Returns
|
|
146
|
+
# { relative_filename => source }. Three kinds live here:
|
|
147
|
+
#
|
|
148
|
+
# - inputs: the named input types, plus everything they transitively
|
|
149
|
+
# reference (nested types stay unaliased — the query module names only the
|
|
150
|
+
# variable roots);
|
|
151
|
+
# - enums: one Ruby type per schema enum a query touched — a generated
|
|
152
|
+
# T::Enum, or the wire tables for one mapped onto an app enum
|
|
153
|
+
# (register_enum) — so a value read out of one query's result hands
|
|
154
|
+
# straight back into another's variable;
|
|
155
|
+
# - unions: each named shared fragment a query spread as a whole union field,
|
|
156
|
+
# so the same union across queries is one Ruby type family. `fragments` is
|
|
157
|
+
# the loaded shared-fragment table (nested spreads resolve through it).
|
|
158
|
+
#
|
|
159
|
+
# Unions are built first: a hoisted fragment's own selections are the one
|
|
160
|
+
# place a query walk never reaches, so the enums they touch are only known
|
|
161
|
+
# once the fragments are built.
|
|
162
|
+
def generate_types(inputs:, enums:, unions:, fragments:)
|
|
163
|
+
validate_module_name!("types module name")
|
|
164
|
+
reset_walk_state!
|
|
165
|
+
# nested spreads inside a shared fragment resolve through the whole table
|
|
166
|
+
@fragments = fragments
|
|
167
|
+
|
|
168
|
+
union_nodes = unions.uniq.sort.map { |name| hoisted_union(fragments, name) }
|
|
169
|
+
inputs.sort.each { |name| input_node(@schema.get_type(name)) }
|
|
170
|
+
enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
|
|
171
|
+
check_shared_collisions!(unions)
|
|
172
|
+
|
|
173
|
+
emit_types_files(union_nodes).tap { report_untyped_scalars }
|
|
138
174
|
end
|
|
139
175
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
176
|
+
# module-level constants every generated query module defines — a shared
|
|
177
|
+
# type aliased to one of these would clash at load
|
|
178
|
+
MODULE_RESERVED = %w[Result QUERY Representations].to_set.freeze
|
|
179
|
+
|
|
180
|
+
# One hoisted shared fragment, built against the schema and named for the
|
|
181
|
+
# fragment rather than the field that spread it.
|
|
182
|
+
def hoisted_union(fragments, name)
|
|
183
|
+
class_name = camelize(name)
|
|
184
|
+
# the query module aliases <class_name> = <shared module>::<class_name>; a
|
|
185
|
+
# name that camelizes to a generated module-level constant (the Result
|
|
186
|
+
# struct, the QUERY heredoc) would collide with that alias at load
|
|
187
|
+
if MODULE_RESERVED.include?(class_name)
|
|
188
|
+
raise GraphWeaver::Error,
|
|
189
|
+
"shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
|
|
143
190
|
end
|
|
144
191
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
192
|
+
fragment = fragments.fetch(name)
|
|
193
|
+
type = @schema.get_type(fragment.type.name)
|
|
194
|
+
members = union_members(type, fragment.selections)
|
|
195
|
+
UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
|
|
196
|
+
end
|
|
197
|
+
private :hoisted_union
|
|
198
|
+
|
|
199
|
+
# Schema type names are unique, so an input and an enum can never land on the
|
|
200
|
+
# same name — but a hoisted union is named for its FRAGMENT, which the schema
|
|
201
|
+
# knows nothing about. One shared module means one namespace, so a fragment
|
|
202
|
+
# named after a type it doesn't describe has to refuse rather than overwrite.
|
|
203
|
+
def check_shared_collisions!(names)
|
|
204
|
+
taken = {}
|
|
205
|
+
@enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
|
|
206
|
+
@mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
|
|
207
|
+
@variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
|
|
208
|
+
|
|
209
|
+
names.each do |name|
|
|
210
|
+
class_name = camelize(name)
|
|
211
|
+
claim = taken[class_name] or next
|
|
152
212
|
|
|
153
|
-
|
|
213
|
+
raise GraphWeaver::Error,
|
|
214
|
+
"shared fragment #{name.inspect} hoists to #{@module_name}::#{class_name}, " \
|
|
215
|
+
"where #{claim} already generates — rename the fragment"
|
|
216
|
+
end
|
|
154
217
|
end
|
|
218
|
+
private :check_shared_collisions!
|
|
155
219
|
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
220
|
+
# per-run walk state, cleared so one Codegen can generate more than once
|
|
221
|
+
def reset_walk_state!
|
|
222
|
+
@enums = {}
|
|
223
|
+
@variable_inputs = {}
|
|
224
|
+
@mapped_enums = {}
|
|
225
|
+
@used_unions = []
|
|
226
|
+
# requires the generated file needs (custom scalars, enum mappings,
|
|
227
|
+
# type helpers all contribute)
|
|
228
|
+
@requires = []
|
|
165
229
|
end
|
|
230
|
+
private :reset_walk_state!
|
|
166
231
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
raise ArgumentError, "unions module name must be a constant name, got #{@module_name.inspect}"
|
|
170
|
-
end
|
|
232
|
+
# generated source is eval'd by parse — never let a name inject code
|
|
233
|
+
CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
171
234
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# nested spreads inside a shared fragment resolve through the whole table
|
|
175
|
-
@fragments = fragments
|
|
235
|
+
def validate_module_name!(subject)
|
|
236
|
+
return if @module_name&.match?(CONSTANT_NAME)
|
|
176
237
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
|
|
185
|
-
end
|
|
186
|
-
fragment = fragments.fetch(name)
|
|
187
|
-
type = @schema.get_type(fragment.type.name)
|
|
188
|
-
UnionNode.new(class_name, union_members(type, fragment.selections))
|
|
189
|
-
end
|
|
238
|
+
problem = "#{subject} must be a constant name, got #{@module_name.inspect}"
|
|
239
|
+
# An explicit module_name: is an argument wrong on its face. A derived one
|
|
240
|
+
# is a verdict on a FILE — a numeric prefix (01_home.graphql) is the usual
|
|
241
|
+
# way in — so it names the file, says the fix is a rename, and brands so
|
|
242
|
+
# `rake graph_weaver:generate` aborts on it instead of burying it under a
|
|
243
|
+
# backtrace through codegen.
|
|
244
|
+
raise ArgumentError, problem unless @path
|
|
190
245
|
|
|
191
|
-
|
|
246
|
+
raise GraphWeaver::Error, "#{@path}: #{problem} — it comes from the file name, so rename the " \
|
|
247
|
+
"file to one a constant can spell (a letter first, then letters, digits or underscores)"
|
|
192
248
|
end
|
|
193
|
-
|
|
194
|
-
# module-level constants every generated query module defines — a hoisted
|
|
195
|
-
# union aliased to one of these would clash at load
|
|
196
|
-
HOISTED_UNION_RESERVED = %w[Result QUERY].to_set.freeze
|
|
249
|
+
private :validate_module_name!
|
|
197
250
|
|
|
198
251
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
199
252
|
|
|
200
|
-
# Names
|
|
201
|
-
#
|
|
202
|
-
#
|
|
253
|
+
# Names generated Ruby can't spell bare — as a kwarg, a local, or a method
|
|
254
|
+
# name. As a prop they're fine (`const :next`), since a prop is only ever
|
|
255
|
+
# read off a receiver.
|
|
203
256
|
RUBY_KEYWORDS = %w[
|
|
204
257
|
alias and begin break case class def defined? do else elsif end
|
|
205
258
|
ensure false for if in module next nil not or redo rescue retry
|
|
@@ -207,6 +260,17 @@ class GraphWeaver::Codegen
|
|
|
207
260
|
BEGIN END __FILE__ __LINE__ __ENCODING__
|
|
208
261
|
].to_set.freeze
|
|
209
262
|
GENERATED_METHODS = %w[serialize to_h].to_set.freeze
|
|
263
|
+
# Names the generated `execute` body owns: the per-call client kwarg and the
|
|
264
|
+
# variables hash it builds. A GraphQL variable by either name redeclares one
|
|
265
|
+
# — `def self.execute(client:, client: nil)` doesn't even parse. No legal
|
|
266
|
+
# Ruby local is unreachable by a GraphQL variable name, so this is a guard
|
|
267
|
+
# rather than a rename.
|
|
268
|
+
RESERVED_KWARGS = %w[client variables].to_set.freeze
|
|
269
|
+
# Every method a struct instance already answers: T::Props refuses to redefine
|
|
270
|
+
# those (`class`, `hash`, `send`, `to_s`), so the generated file would raise
|
|
271
|
+
# ArgumentError at require time. Derived rather than listed, so it tracks
|
|
272
|
+
# whatever the Ruby and sorbet-runtime in play actually define.
|
|
273
|
+
STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
|
|
210
274
|
|
|
211
275
|
def generate
|
|
212
276
|
begin
|
|
@@ -214,21 +278,14 @@ class GraphWeaver::Codegen
|
|
|
214
278
|
rescue GraphQL::ParseError => e
|
|
215
279
|
# unparseable queries wrap like invalid ones — everything raised
|
|
216
280
|
# here descends from GraphWeaver::Error
|
|
217
|
-
raise GraphWeaver::ValidationError.new([
|
|
281
|
+
raise GraphWeaver::ValidationError.new([detail(e.message, e.line, e.col)])
|
|
218
282
|
end
|
|
219
283
|
if errors.any?
|
|
220
284
|
raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
|
|
221
285
|
end
|
|
222
286
|
|
|
223
287
|
validate_registrations!
|
|
224
|
-
|
|
225
|
-
@variable_enums = {}
|
|
226
|
-
@variable_inputs = {}
|
|
227
|
-
@mapped_enums = {}
|
|
228
|
-
@used_unions = []
|
|
229
|
-
# requires the generated file needs (custom scalars, enum mappings,
|
|
230
|
-
# type helpers all contribute)
|
|
231
|
-
@requires = []
|
|
288
|
+
reset_walk_state!
|
|
232
289
|
|
|
233
290
|
operation = load_operation(@query)
|
|
234
291
|
root_type = operation_root_type(operation)
|
|
@@ -238,15 +295,42 @@ class GraphWeaver::Codegen
|
|
|
238
295
|
raise ArgumentError, "module_name: required for anonymous operations"
|
|
239
296
|
end
|
|
240
297
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
298
|
+
validate_module_name!("module_name:")
|
|
299
|
+
|
|
300
|
+
variables = build_variables(operation)
|
|
301
|
+
root = object_node(root_type, operation.selections, "Result")
|
|
302
|
+
|
|
303
|
+
# An anonymous operation takes the module's name — declared in the document
|
|
304
|
+
# AND sent as operationName, which have to agree (a server rejects an
|
|
305
|
+
# operationName the document doesn't declare). The conventional .graphql
|
|
306
|
+
# file names nothing, so without this every trace arrives anonymous.
|
|
307
|
+
operation_name = operation.name || @module_name.split("::").last
|
|
308
|
+
@query = declare_operation_name(operation, operation_name) unless operation.name
|
|
309
|
+
|
|
310
|
+
emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
|
|
311
|
+
.tap { report_untyped_scalars }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# Insert `name` into the operation's own declaration, leaving the rest of the
|
|
317
|
+
# document exactly as written — re-printing the AST would reformat the query
|
|
318
|
+
# the reader reviews. The module name is already constrained to
|
|
319
|
+
# /[A-Z]\w*(::[A-Z]\w*)*/, so its last segment is always a legal GraphQL name.
|
|
320
|
+
def declare_operation_name(operation, name)
|
|
321
|
+
at = @query.lines.first(operation.line - 1).sum(&:length) + operation.col - 1
|
|
322
|
+
keyword = @query[at..].to_s[/\A(?:query|mutation|subscription)\b/]
|
|
323
|
+
return "#{@query[0, at]}query #{name} #{@query[at..]}" unless keyword # `{ ... }` shorthand
|
|
245
324
|
|
|
325
|
+
"#{@query[0, at + keyword.length]} #{name}#{@query[(at + keyword.length)..]}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The operation's variables as execute's kwarg surface: one VarDef each,
|
|
329
|
+
# typed from the AST. A variable is optional when nullable or defaulted —
|
|
330
|
+
# optional kwargs default to nil and are omitted from the wire.
|
|
331
|
+
def build_variables(operation)
|
|
246
332
|
variables = operation.variables.map do |var|
|
|
247
333
|
node = ast_type_ref(var.type)
|
|
248
|
-
# a variable is optional when nullable or defaulted; optional kwargs
|
|
249
|
-
# default to nil and are omitted from the wire
|
|
250
334
|
required = node.non_null? && var.default_value.nil?
|
|
251
335
|
kwarg = underscore(var.name)
|
|
252
336
|
# kwargs are declared and forwarded bare in generated source
|
|
@@ -255,6 +339,11 @@ class GraphWeaver::Codegen
|
|
|
255
339
|
"variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
|
|
256
340
|
"(a Ruby keyword) — rename the variable"
|
|
257
341
|
end
|
|
342
|
+
if RESERVED_KWARGS.include?(kwarg)
|
|
343
|
+
raise GraphWeaver::Error,
|
|
344
|
+
"variable $#{var.name} would become the kwarg '#{kwarg}:', which generated execute already " \
|
|
345
|
+
"uses — rename the variable (query($#{var.name}Id: ...))"
|
|
346
|
+
end
|
|
258
347
|
VarDef.new(kwarg, var.name, node, required)
|
|
259
348
|
end
|
|
260
349
|
|
|
@@ -267,19 +356,139 @@ class GraphWeaver::Codegen
|
|
|
267
356
|
"variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
|
|
268
357
|
end
|
|
269
358
|
|
|
270
|
-
|
|
359
|
+
variables
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Builders for the entity types this query's representation-taking fields
|
|
363
|
+
# can return. The hook is the schema, not the field name: the subgraph spec
|
|
364
|
+
# types a representation as `_Any`, so a field taking one is asking for
|
|
365
|
+
# entity references, and the entity types are the @key'd members its
|
|
366
|
+
# selection names. Query-driven like everything else — a subgraph with
|
|
367
|
+
# fifty entities emits builders only for the ones the query reaches.
|
|
368
|
+
def representation_nodes(operation, root_type)
|
|
369
|
+
nodes = entity_types(operation, root_type).filter_map { |entity| representation_node(entity) }
|
|
370
|
+
|
|
371
|
+
collision = nodes.group_by(&:method_name).find { |_, group| group.size > 1 }
|
|
372
|
+
if collision
|
|
373
|
+
types = collision.last.map(&:graphql_type).join(" and ")
|
|
374
|
+
raise GraphWeaver::Error,
|
|
375
|
+
"entities #{types} both build Representations.#{collision.first} — rename one, or drop it from the selection"
|
|
376
|
+
end
|
|
271
377
|
|
|
272
|
-
|
|
378
|
+
nodes
|
|
273
379
|
end
|
|
274
380
|
|
|
275
|
-
|
|
381
|
+
# The types a representation-taking field's selection names. `_entities` is
|
|
382
|
+
# a root field and the spec defines it nowhere else, so this looks no deeper.
|
|
383
|
+
def entity_types(operation, root_type)
|
|
384
|
+
gather_conditional(root_type, operation.selections).each_value.flat_map { |occurrences|
|
|
385
|
+
fields = occurrences.map(&:first)
|
|
386
|
+
definition = @schema.get_field(root_type.graphql_name, fields.first.name)
|
|
387
|
+
next [] unless definition && representation_field?(definition)
|
|
388
|
+
|
|
389
|
+
core = definition.type.unwrap
|
|
390
|
+
next [] unless %w[UNION INTERFACE].include?(core.kind.name)
|
|
391
|
+
|
|
392
|
+
selected_members(core, fields.flat_map(&:selections))
|
|
393
|
+
}.uniq(&:graphql_name)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# The subgraph spec's representation scalar. A field taking one is the
|
|
397
|
+
# entity resolver, whatever it's called.
|
|
398
|
+
REPRESENTATION_SCALAR = "_Any"
|
|
399
|
+
|
|
400
|
+
def representation_field?(definition)
|
|
401
|
+
definition.arguments.each_value.any? { |argument| argument.type.unwrap.graphql_name == REPRESENTATION_SCALAR }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# A `@key` this subgraph resolves. Matched by local name, since a fed-2
|
|
405
|
+
# subgraph linking the spec under a namespace applies @federation__key;
|
|
406
|
+
# `resolvable: false` declares a key the subgraph explicitly does NOT
|
|
407
|
+
# answer for, so it can't stand behind a representation.
|
|
408
|
+
def resolvable_keys(type)
|
|
409
|
+
return [] unless type.respond_to?(:directives)
|
|
410
|
+
|
|
411
|
+
type.directives.filter_map do |directive|
|
|
412
|
+
name = directive.graphql_name
|
|
413
|
+
next unless name == "key" || name.end_with?("__key")
|
|
414
|
+
|
|
415
|
+
arguments = directive.arguments.keyword_arguments
|
|
416
|
+
next if arguments[:resolvable] == false
|
|
417
|
+
|
|
418
|
+
arguments[:fields]&.to_s
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# An entity's builder, or nil when the type isn't one (no resolvable @key).
|
|
423
|
+
def representation_node(entity)
|
|
424
|
+
key_fields = resolvable_keys(entity)
|
|
425
|
+
key_sets = key_fields.map { |fields| key_paths(entity, fields) }
|
|
426
|
+
return if key_sets.empty?
|
|
427
|
+
|
|
428
|
+
method_name = underscore(entity.graphql_name)
|
|
429
|
+
if RUBY_KEYWORDS.include?(method_name)
|
|
430
|
+
raise GraphWeaver::Error,
|
|
431
|
+
"entity #{entity.graphql_name} would build Representations.#{method_name}, which generated code can't declare (a Ruby keyword)"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
RepresentationNode.new(method_name, entity.graphql_name, key_fields, key_sets,
|
|
435
|
+
key_params(entity, key_sets, required: key_sets.one?))
|
|
436
|
+
end
|
|
276
437
|
|
|
277
|
-
# A
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
438
|
+
# A @key field set is a GraphQL selection set — "upc sku", or a nested
|
|
439
|
+
# "id organization { id }" — so parse it and flatten to the leaf paths the
|
|
440
|
+
# wire hash needs. Dotted, since a GraphQL name can't contain a dot.
|
|
441
|
+
def key_paths(entity, fields)
|
|
442
|
+
selections = GraphQL.parse("{ #{fields} }").definitions.first.selections
|
|
443
|
+
leaf_paths(selections)
|
|
444
|
+
rescue GraphQL::ParseError => e
|
|
445
|
+
raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def leaf_paths(selections, prefix = [])
|
|
449
|
+
selections.flat_map do |node|
|
|
450
|
+
path = prefix + [node.name]
|
|
451
|
+
node.selections.empty? ? [path.join(".")] : leaf_paths(node.selections, path)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The kwargs a builder takes: every key set's top-level field, once. Typed
|
|
456
|
+
# from the schema — a leaf key field gets its registered scalar's Ruby
|
|
457
|
+
# type, a nested one an open Hash whose shape the runtime checks.
|
|
458
|
+
def key_params(entity, key_sets, required:)
|
|
459
|
+
key_sets.flatten.map { |path| path.split(".").first }.uniq.map do |name|
|
|
460
|
+
field = @schema.get_field(entity.graphql_name, name)
|
|
461
|
+
unless field
|
|
462
|
+
raise GraphWeaver::Error, "#{entity.graphql_name} @key names #{name.inspect}, which the type doesn't declare"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
kwarg = underscore(name)
|
|
466
|
+
if RUBY_KEYWORDS.include?(kwarg)
|
|
467
|
+
raise GraphWeaver::Error,
|
|
468
|
+
"#{entity.graphql_name} @key field #{name.inspect} would become the kwarg '#{kwarg}:', " \
|
|
469
|
+
"which generated code can't declare (a Ruby keyword)"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
core = field.type.unwrap
|
|
473
|
+
if core.kind.name == "SCALAR"
|
|
474
|
+
node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
|
|
475
|
+
type = required ? node.bare_type : node.prop_type
|
|
476
|
+
value = node.serialize_identity? ? kwarg : "#{kwarg}&.then { |v1| #{node.serialize("v1", 2)} }"
|
|
477
|
+
else
|
|
478
|
+
# a nested key set — or an enum/composite one — passes through as an
|
|
479
|
+
# open hash, narrowed to the declared sub-paths by the runtime
|
|
480
|
+
type = "T::Hash[T.untyped, T.untyped]"
|
|
481
|
+
type = "T.nilable(#{type})" unless required
|
|
482
|
+
value = kwarg
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
RepresentationNode::Param.new(kwarg, name, type, value, required)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# A registration names a type in a specific schema — a typo'd name would
|
|
490
|
+
# otherwise be a silent no-op, the most confusing failure mode available.
|
|
491
|
+
# Called at generation for every registration in play.
|
|
283
492
|
def self.validate_registration!(schema, kind, name)
|
|
284
493
|
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
285
494
|
# the field exists and is a scalar, not that a type named "Type.field" exists.
|
|
@@ -314,13 +523,19 @@ class GraphWeaver::Codegen
|
|
|
314
523
|
# map — reusable fragments a query can spread. Fragment files hold only
|
|
315
524
|
# fragments (no operations); names are unique across them.
|
|
316
525
|
def self.load_fragments(paths)
|
|
317
|
-
|
|
318
|
-
|
|
526
|
+
source = {} # fragment name => the file that defined it, for the collision message
|
|
527
|
+
|
|
528
|
+
Array(paths).flat_map { |dir| Dir[File.join(dir, DOCUMENT_GLOB)].sort }.each_with_object({}) do |file, out|
|
|
529
|
+
doc = parse_document(File.read(file), file)
|
|
319
530
|
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
320
531
|
raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
|
|
321
532
|
end
|
|
322
533
|
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
323
|
-
|
|
534
|
+
if (earlier = source[frag.name])
|
|
535
|
+
raise GraphWeaver::Error,
|
|
536
|
+
"duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{file}; rename one"
|
|
537
|
+
end
|
|
538
|
+
source[frag.name] = file
|
|
324
539
|
out[frag.name] = frag
|
|
325
540
|
end
|
|
326
541
|
end
|
|
@@ -330,18 +545,34 @@ class GraphWeaver::Codegen
|
|
|
330
545
|
# shadows with a local definition of the same name — the names
|
|
331
546
|
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
332
547
|
# when they sit on a whole-union field.
|
|
333
|
-
def self.shared_fragment_spreads(query, shared)
|
|
548
|
+
def self.shared_fragment_spreads(query, shared, path = nil)
|
|
549
|
+
# parsed even with nothing to spread: this is the first look at the document
|
|
550
|
+
# on the generate! path, so it's where a syntax error gets branded and
|
|
551
|
+
# pinned to the file it came from
|
|
552
|
+
doc = parse_document(query, path)
|
|
334
553
|
return [] if shared.empty?
|
|
335
554
|
|
|
336
|
-
doc = GraphQL.parse(query)
|
|
337
555
|
local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
|
|
338
556
|
reachable_fragments(fragment_spreads(doc.definitions), shared, local)
|
|
339
557
|
end
|
|
340
558
|
|
|
559
|
+
# Parse a GraphQL document, branding graphql-ruby's ParseError under the
|
|
560
|
+
# umbrella and naming the file it came from — its own location is a line and
|
|
561
|
+
# column in a document the caller never sees. This runs on the generate! path
|
|
562
|
+
# BEFORE Codegen#generate's rescue, so it needs its own guard.
|
|
563
|
+
def self.parse_document(query, path = nil)
|
|
564
|
+
GraphQL.parse(query)
|
|
565
|
+
rescue GraphQL::ParseError => e
|
|
566
|
+
prefix = [path, e.line, e.col].compact.join(":")
|
|
567
|
+
raise GraphWeaver::ValidationError.new(
|
|
568
|
+
[{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
|
|
569
|
+
)
|
|
570
|
+
end
|
|
571
|
+
|
|
341
572
|
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
342
573
|
# the sent query is self-contained. Unused shared fragments are left out.
|
|
343
|
-
def self.inline_fragments(query, shared)
|
|
344
|
-
used = shared_fragment_spreads(query, shared)
|
|
574
|
+
def self.inline_fragments(query, shared, path = nil)
|
|
575
|
+
used = shared_fragment_spreads(query, shared, path)
|
|
345
576
|
return query if used.empty?
|
|
346
577
|
|
|
347
578
|
"#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
|
|
@@ -377,16 +608,30 @@ class GraphWeaver::Codegen
|
|
|
377
608
|
# source location, so ValidationError#errors is inspectable.
|
|
378
609
|
def validation_detail(error)
|
|
379
610
|
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
380
|
-
|
|
611
|
+
detail(error.message, loc && loc["line"], loc && loc["column"])
|
|
381
612
|
end
|
|
382
613
|
|
|
614
|
+
# One ValidationError entry, its message prefixed "file:line:col" like a
|
|
615
|
+
# compiler — the position is captured either way, and without it a project
|
|
616
|
+
# with thirty query files leaves the reader hunting for the typo.
|
|
617
|
+
def detail(message, line, column)
|
|
618
|
+
prefix = [@path, line, column].compact.join(":")
|
|
619
|
+
{ message: prefix.empty? ? message : "#{prefix} #{message}", line:, column: }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Every registration this generation could consult. The built-in scalars are
|
|
623
|
+
# pre-registered entries in the same table rather than user intent, so
|
|
624
|
+
# they're exempt — a schema with no Date scalar is not a mistake.
|
|
383
625
|
def validate_registrations!
|
|
384
|
-
{
|
|
626
|
+
{
|
|
627
|
+
"enum" => GraphWeaver::Codegen.enum_registry,
|
|
628
|
+
"scalar" => GraphWeaver::Codegen.scalar_registry.except(*BUILTIN_SCALARS),
|
|
629
|
+
"type" => GraphWeaver::Codegen.type_registry,
|
|
630
|
+
}.each do |kind, registry|
|
|
385
631
|
registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
|
|
386
632
|
end
|
|
387
633
|
end
|
|
388
634
|
|
|
389
|
-
|
|
390
635
|
def object_node(type, selections, class_name)
|
|
391
636
|
node = ObjectNode.new(class_name)
|
|
392
637
|
node.graphql_type = type.graphql_name
|
|
@@ -396,10 +641,13 @@ class GraphWeaver::Codegen
|
|
|
396
641
|
# same union selected two ways (unblockOptions vs selectedOption) shares
|
|
397
642
|
# one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
|
|
398
643
|
union_cache = {}
|
|
644
|
+
props = {}
|
|
399
645
|
|
|
400
|
-
|
|
646
|
+
gather_conditional(type, selections).each do |key, occurrences|
|
|
647
|
+
field_nodes = occurrences.map(&:first)
|
|
401
648
|
field_name = field_nodes.first.name
|
|
402
649
|
prop = underscore(key)
|
|
650
|
+
check_output_prop!(type, key, prop, props)
|
|
403
651
|
|
|
404
652
|
child = if field_name == "__typename"
|
|
405
653
|
NonNull.new(scalar_node("String"))
|
|
@@ -407,59 +655,66 @@ class GraphWeaver::Codegen
|
|
|
407
655
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
408
656
|
sub_selections = field_nodes.flat_map(&:selections)
|
|
409
657
|
|
|
410
|
-
case (core = unwrap
|
|
658
|
+
case (core = field_type.unwrap).kind.name
|
|
411
659
|
when "OBJECT"
|
|
412
|
-
name = pick_name(
|
|
660
|
+
name = pick_name(key, taken)
|
|
413
661
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
414
662
|
when "UNION", "INTERFACE"
|
|
415
663
|
conditions = concrete_conditions(core, sub_selections)
|
|
416
664
|
bare = bare_fields(sub_selections) - ["__typename"]
|
|
417
665
|
|
|
418
|
-
if conditions.empty?
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
|
|
666
|
+
if conditions.empty?
|
|
667
|
+
# abstract-level fields only — every member shares them, so one
|
|
668
|
+
# struct suffices and no __typename dispatch is needed (for a
|
|
669
|
+
# union that selection can only be __typename)
|
|
670
|
+
name = pick_name(key, taken)
|
|
422
671
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
423
672
|
elsif conditions.size == 1 && bare.empty? &&
|
|
424
673
|
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
425
674
|
# a single `... on X` condition: narrow to X's struct — nil
|
|
426
675
|
# when the runtime type doesn't match (narrowing filters).
|
|
427
|
-
#
|
|
428
|
-
#
|
|
429
|
-
# @skip/@include would
|
|
430
|
-
# from a miss ({} either
|
|
431
|
-
|
|
676
|
+
# With `__typename` selected the match is read off the tag;
|
|
677
|
+
# without one there is nothing to read but emptiness, and a
|
|
678
|
+
# fragment whose every field hides behind @skip/@include would
|
|
679
|
+
# make a real match indistinguishable from a miss ({} either
|
|
680
|
+
# way) — refuse rather than guess.
|
|
681
|
+
tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
|
|
682
|
+
unless tag || unconditional_field?(member, sub_selections)
|
|
432
683
|
raise GraphWeaver::Error,
|
|
433
684
|
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
434
|
-
"@skip/@include — an all-conditional selection
|
|
685
|
+
"@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
|
|
686
|
+
"makes a match indistinguishable from nil"
|
|
435
687
|
end
|
|
436
688
|
|
|
437
|
-
name = pick_name(
|
|
438
|
-
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
439
|
-
elsif @
|
|
689
|
+
name = pick_name(key, taken)
|
|
690
|
+
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
|
|
691
|
+
elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
|
|
440
692
|
@hoistable_unions.include?(frag)
|
|
441
693
|
# a whole-union field spread as a named shared fragment: hoist to
|
|
442
|
-
# the shared
|
|
694
|
+
# the shared types module so the same union across queries is one
|
|
443
695
|
# Ruby type family (one exhaustive `case ... T.absurd`).
|
|
444
696
|
@used_unions << frag unless @used_unions.include?(frag)
|
|
445
697
|
ref = UnionRefNode.new(camelize(frag))
|
|
446
698
|
type_ref(field_type) { ref }
|
|
447
699
|
else
|
|
448
700
|
members = union_members(core, sub_selections)
|
|
449
|
-
|
|
450
|
-
union
|
|
451
|
-
|
|
701
|
+
catch_all = catch_all_member(core, sub_selections, members)
|
|
702
|
+
# reuse an identical sibling union — the shared type takes the
|
|
703
|
+
# first of the sharing keys alphabetically, not in walk order
|
|
704
|
+
signature = union_signature(members, catch_all)
|
|
705
|
+
union = union_cache[signature]
|
|
706
|
+
if union
|
|
707
|
+
rename_union(union, key, taken) if camelize(key) < union.class_name
|
|
708
|
+
else
|
|
709
|
+
union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
|
|
710
|
+
end
|
|
452
711
|
type_ref(field_type) { union }
|
|
453
712
|
end
|
|
454
713
|
when "ENUM"
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
# sorted so output is deterministic across schema sources
|
|
460
|
-
# (SDL round-trips reorder values alphabetically)
|
|
461
|
-
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
462
|
-
end
|
|
714
|
+
# one schema enum is one Ruby type: module-level, named for the enum,
|
|
715
|
+
# shared by every result field and variable that reaches it (and, on
|
|
716
|
+
# the generate! path, by every query module — see types_namespace)
|
|
717
|
+
type_ref(field_type) { variable_core(core) }
|
|
463
718
|
when "SCALAR"
|
|
464
719
|
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
465
720
|
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
@@ -468,9 +723,11 @@ class GraphWeaver::Codegen
|
|
|
468
723
|
end
|
|
469
724
|
end
|
|
470
725
|
|
|
471
|
-
#
|
|
472
|
-
#
|
|
473
|
-
|
|
726
|
+
# A field under @skip/@include — on the field itself, or on any fragment
|
|
727
|
+
# it was reached through — may be absent from the response no matter what
|
|
728
|
+
# the schema says, so its type must admit nil. One unconditional
|
|
729
|
+
# selection of the same key still guarantees it, though.
|
|
730
|
+
if occurrences.all? { |node, conditional| conditional || conditional?(node) }
|
|
474
731
|
child = child.of if child.is_a?(NonNull)
|
|
475
732
|
end
|
|
476
733
|
|
|
@@ -481,141 +738,27 @@ class GraphWeaver::Codegen
|
|
|
481
738
|
node
|
|
482
739
|
end
|
|
483
740
|
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
def
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
check_alias_name!(node, name)
|
|
494
|
-
begin
|
|
495
|
-
resolve_alias(node, name, spec[:segments])
|
|
496
|
-
rescue GraphWeaver::Error
|
|
497
|
-
# a path that doesn't fit THIS query's selection: strict raises,
|
|
498
|
-
# optional simply omits the accessor
|
|
499
|
-
raise unless spec[:optional]
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
def check_alias_name!(node, name)
|
|
505
|
-
if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
|
|
741
|
+
# Both ways a result key can fail to become a prop — a name the struct
|
|
742
|
+
# already answers, or a second key that underscores onto an earlier one.
|
|
743
|
+
# Either emits a file that raises ArgumentError at require time, so refuse
|
|
744
|
+
# here; an alias in the query fixes both. `props` accumulates prop => key.
|
|
745
|
+
def check_output_prop!(type, key, prop, props)
|
|
746
|
+
# Keywords are fine: `const :next` and `next: data["next"]` are legal, and
|
|
747
|
+
# the one place a prop is read bare (an alias delegator) qualifies it.
|
|
748
|
+
# `pageInfo { next }` and `filter { in }` are ordinary API shapes.
|
|
749
|
+
if STRUCT_METHODS.include?(prop)
|
|
506
750
|
raise GraphWeaver::Error,
|
|
507
|
-
"
|
|
508
|
-
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Registered aliases for a GraphQL type: global registry plus this client's
|
|
512
|
-
# overlay (client-scoped wins on a name clash).
|
|
513
|
-
def type_aliases(graphql_name)
|
|
514
|
-
global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
515
|
-
(global.merge(@types[graphql_name]&.dig(:aliases) || {}))
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
|
|
519
|
-
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
520
|
-
# (the list may be empty). Everything else is a field prop.
|
|
521
|
-
LIST_SELECTORS = %w[first last].freeze
|
|
522
|
-
|
|
523
|
-
# Walk a dotted path through this struct's selected shape, building the
|
|
524
|
-
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
525
|
-
# type. A segment is a field prop, or `first`/`last` to pick a list element.
|
|
526
|
-
# Everything is checked against the node tree: a field on a non-object, a
|
|
527
|
-
# selector on a non-list, or an unselected segment raises. Any nilable hop
|
|
528
|
-
# (a nullable field, or a list element) makes the accessor nilable.
|
|
529
|
-
def resolve_alias(node, name, segments)
|
|
530
|
-
cur = T.let(node, T.untyped) # the node the path has reached
|
|
531
|
-
cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
|
|
532
|
-
nilable = T.let(false, T::Boolean) # is the accessor overall nilable
|
|
533
|
-
containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
|
|
534
|
-
expr = +""
|
|
535
|
-
|
|
536
|
-
segments.each do |seg|
|
|
537
|
-
connector = expr.empty? ? "" : (cur_nilable ? "&." : ".")
|
|
538
|
-
|
|
539
|
-
# `first`/`last` select an element only when the current hop is actually a
|
|
540
|
-
# list; otherwise they're an ordinary field (a schema field named `first`)
|
|
541
|
-
if LIST_SELECTORS.include?(seg) && list_of(cur)
|
|
542
|
-
expr << connector << seg
|
|
543
|
-
cur = list_of(cur).of
|
|
544
|
-
cur_nilable = true # first/last is nil on an empty list
|
|
545
|
-
nilable = true
|
|
546
|
-
else
|
|
547
|
-
obj = object_of(cur)
|
|
548
|
-
unless obj
|
|
549
|
-
hint = if list_of(cur)
|
|
550
|
-
" — use .first or .last to pick an element"
|
|
551
|
-
elsif LIST_SELECTORS.include?(seg)
|
|
552
|
-
" — .#{seg} needs a list"
|
|
553
|
-
else
|
|
554
|
-
""
|
|
555
|
-
end
|
|
556
|
-
raise GraphWeaver::Error,
|
|
557
|
-
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
|
|
558
|
-
end
|
|
559
|
-
# the object a field is read from is the lexical container of its result
|
|
560
|
-
# (nested structs emit inside their parent); the aliased struct itself is
|
|
561
|
-
# the delegator's own scope, so it contributes no prefix
|
|
562
|
-
containers << obj.class_name unless obj.equal?(node)
|
|
563
|
-
field = obj.fields.find { |f| f.prop == seg }
|
|
564
|
-
unless field
|
|
565
|
-
props = obj.fields.map(&:prop)
|
|
566
|
-
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
567
|
-
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
568
|
-
raise GraphWeaver::Error,
|
|
569
|
-
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
570
|
-
end
|
|
571
|
-
expr << connector << seg
|
|
572
|
-
cur = field.node
|
|
573
|
-
cur_nilable = !field.node.non_null?
|
|
574
|
-
nilable ||= cur_nilable
|
|
575
|
-
end
|
|
751
|
+
"#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
|
|
752
|
+
"already defines — alias it in the query (`#{prop}Value: #{key}`)"
|
|
576
753
|
end
|
|
577
754
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
# The leaf's Sorbet type as referenced from the aliased struct. Generated
|
|
584
|
-
# nested constants (structs, enums, unions) must carry the container path,
|
|
585
|
-
# since the delegator's `sig` is emitted in an outer struct where a bare
|
|
586
|
-
# `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
|
|
587
|
-
# already top-level. `containers` is the class-name chain to the leaf.
|
|
588
|
-
def qualified_alias_type(node, containers)
|
|
589
|
-
node = node.of if node.is_a?(NonNull)
|
|
590
|
-
prefix = containers.empty? ? "" : "#{containers.join("::")}::"
|
|
591
|
-
|
|
592
|
-
case node
|
|
593
|
-
when List
|
|
594
|
-
element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
|
|
595
|
-
inner = qualified_alias_type(node.of, containers)
|
|
596
|
-
inner == "T.untyped" ? inner : "T.nilable(#{inner})"
|
|
597
|
-
end
|
|
598
|
-
"T::Array[#{element}]"
|
|
599
|
-
when ObjectNode, EnumNode, NarrowedNode then "#{prefix}#{node.class_name}"
|
|
600
|
-
when UnionNode then "#{prefix}#{node.bare_type}"
|
|
601
|
-
else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
|
|
755
|
+
if (earlier = props[prop])
|
|
756
|
+
raise GraphWeaver::Error,
|
|
757
|
+
"result keys #{earlier.inspect} and #{key.inspect} on #{type.graphql_name} both map to the " \
|
|
758
|
+
"prop '#{prop}' — alias one to a distinct name"
|
|
602
759
|
end
|
|
603
|
-
end
|
|
604
760
|
|
|
605
|
-
|
|
606
|
-
def list_of(node)
|
|
607
|
-
node = T.let(node, T.untyped)
|
|
608
|
-
node = node.of while node.is_a?(NonNull)
|
|
609
|
-
node if node.is_a?(List)
|
|
610
|
-
end
|
|
611
|
-
|
|
612
|
-
# the ObjectNode a node resolves to for field access (through NON_NULL and a
|
|
613
|
-
# narrowed abstract member), or nil — unions/scalars/lists can't be read into
|
|
614
|
-
def object_of(node)
|
|
615
|
-
node = T.let(node, T.untyped)
|
|
616
|
-
node = node.of while node.is_a?(NonNull)
|
|
617
|
-
node = node.nested if node.is_a?(NarrowedNode)
|
|
618
|
-
node if node.is_a?(ObjectNode)
|
|
761
|
+
props[prop] = key
|
|
619
762
|
end
|
|
620
763
|
|
|
621
764
|
# The concrete type conditions a selection mentions, minus conditions naming
|
|
@@ -657,12 +800,23 @@ class GraphWeaver::Codegen
|
|
|
657
800
|
# does the flattened selection (as seen by member) include at least one
|
|
658
801
|
# field guaranteed to be present in a matching response?
|
|
659
802
|
def unconditional_field?(member, selections)
|
|
660
|
-
each_field(member, selections) do |_key, node|
|
|
661
|
-
return true if
|
|
803
|
+
each_field(member, selections) do |_key, node, conditional|
|
|
804
|
+
return true if !conditional && !conditional?(node)
|
|
662
805
|
end
|
|
663
806
|
false
|
|
664
807
|
end
|
|
665
808
|
|
|
809
|
+
# Is the response guaranteed to carry a plain "__typename" key for this
|
|
810
|
+
# abstract selection? Every dispatch reads the tag unguarded, so an alias
|
|
811
|
+
# (which files it under another key) or an @skip/@include (which may drop
|
|
812
|
+
# it) means there is no tag to dispatch on.
|
|
813
|
+
def dispatchable_typename?(type, selections)
|
|
814
|
+
occurrences = gather_conditional(type, selections)["__typename"]
|
|
815
|
+
!!occurrences&.any? do |node, conditional|
|
|
816
|
+
node.name == "__typename" && !conditional && !conditional?(node)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
666
820
|
# rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
|
|
667
821
|
# whenever the runtime type doesn't match, whatever the schema promises
|
|
668
822
|
def nilable_type_ref(type, &core)
|
|
@@ -676,29 +830,65 @@ class GraphWeaver::Codegen
|
|
|
676
830
|
end
|
|
677
831
|
end
|
|
678
832
|
|
|
679
|
-
# Abstract types (unions AND interfaces) whose selections vary by
|
|
680
|
-
#
|
|
681
|
-
#
|
|
682
|
-
# interface
|
|
683
|
-
#
|
|
684
|
-
#
|
|
685
|
-
# it; for interfaces the interface-level fields gather into every member.
|
|
833
|
+
# Abstract types (unions AND interfaces) whose selections vary by concrete
|
|
834
|
+
# type: one member struct per type the selection NAMES (graphql type name =>
|
|
835
|
+
# ObjectNode, sorted for deterministic output), never one per schema member —
|
|
836
|
+
# a query against an interface with 278 implementations types the two it asked
|
|
837
|
+
# about. Dispatch reads __typename, so the query must select it; for
|
|
838
|
+
# interfaces the interface-level fields gather into every member.
|
|
686
839
|
def union_members(type, selections)
|
|
687
|
-
unless
|
|
840
|
+
unless dispatchable_typename?(type, selections)
|
|
688
841
|
raise ArgumentError,
|
|
689
|
-
"select __typename on #{type.graphql_name} so the union can dispatch — " \
|
|
690
|
-
"
|
|
842
|
+
"select __typename on #{type.graphql_name} so the union can dispatch — unaliased and " \
|
|
843
|
+
"not under @skip/@include, since from_h reads it on every response — or narrow to a " \
|
|
844
|
+
"single `... on Type` condition (no dispatch needed)"
|
|
691
845
|
end
|
|
692
846
|
|
|
693
|
-
|
|
847
|
+
selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
|
|
694
848
|
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
695
849
|
end
|
|
696
850
|
end
|
|
697
851
|
|
|
852
|
+
# The concrete types a selection names through its type conditions, kept to
|
|
853
|
+
# the abstract type's own members. A condition naming another abstract type
|
|
854
|
+
# (`... on Named` inside a union) stands for the members it covers, since its
|
|
855
|
+
# fields are typed per member.
|
|
856
|
+
def selected_members(type, selections)
|
|
857
|
+
possible = @schema.possible_types(type).to_h { |member| [member.graphql_name, member] }
|
|
858
|
+
|
|
859
|
+
concrete_conditions(type, selections).flat_map { |name|
|
|
860
|
+
condition = @schema.get_type(name)
|
|
861
|
+
condition.kind.name == "OBJECT" ? [condition] : @schema.possible_types(condition)
|
|
862
|
+
}.map(&:graphql_name).uniq.filter_map { |name| possible[name] }
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# The one struct everything else deserializes into: a member the query didn't
|
|
866
|
+
# name, and — the point — a member the schema grows AFTER this file was
|
|
867
|
+
# generated. It carries only what the abstract type itself guarantees (an
|
|
868
|
+
# interface's selected interface-level fields; for a union, just __typename),
|
|
869
|
+
# so a new upstream member bends the result rather than breaking it.
|
|
870
|
+
def catch_all_member(type, selections, members)
|
|
871
|
+
object_node(type, selections, catch_all_name(members))
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# "Other", unless a real member already claims that name.
|
|
875
|
+
def catch_all_name(members)
|
|
876
|
+
taken = members.each_value.map(&:class_name)
|
|
877
|
+
name = "Other"
|
|
878
|
+
suffix = 2
|
|
879
|
+
while taken.include?(name)
|
|
880
|
+
name = "Other#{suffix}"
|
|
881
|
+
suffix += 1
|
|
882
|
+
end
|
|
883
|
+
name
|
|
884
|
+
end
|
|
885
|
+
|
|
698
886
|
# A name-independent structural fingerprint of a union's members, so two
|
|
699
887
|
# occurrences that generate identical structs collapse to one Ruby type.
|
|
700
|
-
def union_signature(members)
|
|
701
|
-
members.map { |gname, member| "#{gname}=#{signature(member)}" }
|
|
888
|
+
def union_signature(members, catch_all = nil)
|
|
889
|
+
parts = members.map { |gname, member| "#{gname}=#{signature(member)}" }
|
|
890
|
+
parts << "*=#{signature(catch_all)}" if catch_all
|
|
891
|
+
parts.sort.join(",")
|
|
702
892
|
end
|
|
703
893
|
|
|
704
894
|
# Structural signature of a node — ignores the generated class name (which
|
|
@@ -715,7 +905,7 @@ class GraphWeaver::Codegen
|
|
|
715
905
|
when ObjectNode
|
|
716
906
|
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
717
907
|
"o:#{node.graphql_type}(#{inner})"
|
|
718
|
-
when UnionNode then "u:(#{union_signature(node.members)})"
|
|
908
|
+
when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
|
|
719
909
|
when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
|
|
720
910
|
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
721
911
|
end
|
|
@@ -742,8 +932,7 @@ class GraphWeaver::Codegen
|
|
|
742
932
|
when "SCALAR"
|
|
743
933
|
scalar_node(core.graphql_name)
|
|
744
934
|
when "ENUM"
|
|
745
|
-
mapped_enum_node(core) || (@
|
|
746
|
-
EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
|
|
935
|
+
mapped_enum_node(core) || (@enums[core.graphql_name] ||= enum_node(core))
|
|
747
936
|
when "INPUT_OBJECT"
|
|
748
937
|
input_node(core)
|
|
749
938
|
else
|
|
@@ -759,40 +948,70 @@ class GraphWeaver::Codegen
|
|
|
759
948
|
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
760
949
|
|
|
761
950
|
node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
|
|
951
|
+
node.one_of = core.respond_to?(:one_of?) && core.one_of?
|
|
762
952
|
# sorted so output is deterministic across schema sources
|
|
763
953
|
core.arguments.values.sort_by(&:graphql_name).each do |argument|
|
|
764
954
|
prop = underscore(argument.graphql_name)
|
|
765
|
-
#
|
|
766
|
-
|
|
955
|
+
# Keywords are fine here: nothing reads an input prop bare (serialize goes
|
|
956
|
+
# through public_send), and `const :in` is legal — which matters, since a
|
|
957
|
+
# schema's field name is not the user's to rename. `Tricky.in` filters are
|
|
958
|
+
# standard Hasura/Gatsby shape.
|
|
959
|
+
if STRUCT_METHODS.include?(prop)
|
|
767
960
|
raise GraphWeaver::Error,
|
|
768
961
|
"input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
|
|
769
|
-
"which collides with
|
|
962
|
+
"which collides with a method every struct defines"
|
|
770
963
|
end
|
|
771
964
|
|
|
772
|
-
child = type_ref(argument.type) { variable_core(
|
|
965
|
+
child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
|
|
773
966
|
required = child.non_null? && !argument.default_value?
|
|
774
967
|
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
775
968
|
end
|
|
776
969
|
node
|
|
777
970
|
end
|
|
778
971
|
|
|
779
|
-
# The
|
|
780
|
-
#
|
|
972
|
+
# The module-level T::Enum for a schema enum, named for the enum itself —
|
|
973
|
+
# it is shared by every field and variable of that type.
|
|
974
|
+
def enum_node(core)
|
|
975
|
+
class_name = camelize(core.graphql_name)
|
|
976
|
+
if MODULE_RESERVED.include?(class_name)
|
|
977
|
+
raise GraphWeaver::Error,
|
|
978
|
+
"enum #{core.graphql_name} generates #{class_name}, which collides with a generated " \
|
|
979
|
+
"constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
EnumNode.new(class_name, enum_values(core))
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# A schema enum's wire values, sorted so output is deterministic across schema
|
|
986
|
+
# sources (SDL round-trips reorder values alphabetically). Values that differ
|
|
987
|
+
# only in case name the same T::Enum constant, which raises at LOAD time
|
|
988
|
+
# ("Enum values must be assigned to constants") — catch it here instead.
|
|
989
|
+
def enum_values(core)
|
|
990
|
+
values = core.values.keys.sort
|
|
991
|
+
collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
|
|
992
|
+
if collision
|
|
993
|
+
raise GraphWeaver::Error,
|
|
994
|
+
"enum #{core.graphql_name} values #{collision.last.join(" and ")} both become the constant " \
|
|
995
|
+
"#{collision.first} — map the enum onto one of yours: " \
|
|
996
|
+
"register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
997
|
+
end
|
|
781
998
|
|
|
999
|
+
values
|
|
1000
|
+
end
|
|
782
1001
|
|
|
783
|
-
# Registered helper-module names for a GraphQL type
|
|
784
|
-
# registrations plus this client's), collecting their requires.
|
|
1002
|
+
# Registered helper-module names for a GraphQL type, collecting their requires.
|
|
785
1003
|
def type_mixins(graphql_name)
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1004
|
+
entry = GraphWeaver::Codegen.type_registry[graphql_name]
|
|
1005
|
+
return [] unless entry
|
|
1006
|
+
|
|
1007
|
+
@requires.concat(entry[:requires])
|
|
1008
|
+
entry[:mixins].map(&:name)
|
|
789
1009
|
end
|
|
790
1010
|
|
|
791
1011
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
792
|
-
# mapping
|
|
793
|
-
# unregistered, falling back to a generated T::Enum.
|
|
1012
|
+
# mapping; nil when unregistered, falling back to a generated T::Enum.
|
|
794
1013
|
def mapped_enum_node(core)
|
|
795
|
-
enum_type =
|
|
1014
|
+
enum_type = GraphWeaver::Codegen.enum_registry[core.graphql_name]
|
|
796
1015
|
return unless enum_type
|
|
797
1016
|
|
|
798
1017
|
@requires.concat(enum_type.requires)
|
|
@@ -802,16 +1021,32 @@ class GraphWeaver::Codegen
|
|
|
802
1021
|
# A Scalar node, recording any requires its registered type needs so the
|
|
803
1022
|
# generated file can require them (collected across the whole query).
|
|
804
1023
|
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
805
|
-
# the scalar-name registration
|
|
1024
|
+
# the scalar-name registration.
|
|
806
1025
|
def scalar_node(name, coordinate = nil)
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1026
|
+
registry = GraphWeaver::Codegen.scalar_registry
|
|
1027
|
+
scalar = (coordinate && registry[coordinate]) || registry[name.to_s]
|
|
1028
|
+
if scalar.nil?
|
|
1029
|
+
@untyped_scalars << name.to_s
|
|
1030
|
+
scalar = GraphWeaver::Codegen.scalar(name)
|
|
1031
|
+
end
|
|
811
1032
|
@requires.concat(scalar.requires)
|
|
812
1033
|
Scalar.new(scalar)
|
|
813
1034
|
end
|
|
814
1035
|
|
|
1036
|
+
# An unregistered custom scalar passes through as T.untyped — legitimate
|
|
1037
|
+
# (nobody needs a codec for every scalar), but it's the one hole in an
|
|
1038
|
+
# otherwise exact result type, so name the holes rather than leave them
|
|
1039
|
+
# silent. Informational: not a warning, never an error.
|
|
1040
|
+
def report_untyped_scalars
|
|
1041
|
+
names = @untyped_scalars.uniq.sort
|
|
1042
|
+
return if names.empty?
|
|
1043
|
+
|
|
1044
|
+
GraphWeaver.log(:info) do
|
|
1045
|
+
"#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
|
|
1046
|
+
"#{names.join(", ")} (register with GraphWeaver.register_scalar)"
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
|
|
815
1050
|
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
816
1051
|
def type_ref(type, &core)
|
|
817
1052
|
case type.kind.name
|
|
@@ -824,21 +1059,45 @@ class GraphWeaver::Codegen
|
|
|
824
1059
|
end
|
|
825
1060
|
end
|
|
826
1061
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1062
|
+
# A generated type is named for the response key that selects it, camelized
|
|
1063
|
+
# (`stargazers` => Stargazers) — a function of the field's own position and
|
|
1064
|
+
# nothing else, so adding, removing, or reordering an unrelated selection can
|
|
1065
|
+
# never rename it. Generated code is app-code API; a name that shifts under
|
|
1066
|
+
# an unrelated edit is a silent break. `taken` is the names claimed in this
|
|
1067
|
+
# struct's scope, its first entry the struct itself.
|
|
1068
|
+
#
|
|
1069
|
+
# (Union members are the exception: they are named for the type condition
|
|
1070
|
+
# that produces them, which is equally position-determined.)
|
|
1071
|
+
def pick_name(key, taken)
|
|
1072
|
+
name = camelize(key)
|
|
1073
|
+
|
|
1074
|
+
# a key that camelizes to no constant at all ("_", "_1") would emit
|
|
1075
|
+
# `class < T::Struct`
|
|
1076
|
+
unless name.match?(/\A[A-Z]/)
|
|
1077
|
+
raise GraphWeaver::Error,
|
|
1078
|
+
"result key #{key.inspect} makes no class name (#{name.inspect}) — alias it to one starting with a letter"
|
|
1079
|
+
end
|
|
831
1080
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1081
|
+
if name == taken.first
|
|
1082
|
+
# would shadow the struct it nests in — the parent's own `returns(Name)`
|
|
1083
|
+
# resolves lexically and would find the child
|
|
1084
|
+
suffix = 2
|
|
1085
|
+
suffix += 1 while taken.include?("#{name}#{suffix}")
|
|
1086
|
+
name = "#{name}#{suffix}"
|
|
1087
|
+
elsif taken.include?(name)
|
|
1088
|
+
raise GraphWeaver::Error,
|
|
1089
|
+
"result keys on #{taken.first} both generate the class #{name} — alias one to a distinct name"
|
|
1090
|
+
end
|
|
839
1091
|
|
|
840
|
-
taken <<
|
|
841
|
-
|
|
1092
|
+
taken << name
|
|
1093
|
+
name
|
|
842
1094
|
end
|
|
843
1095
|
|
|
1096
|
+
# Fields whose union selections are structurally identical share one Ruby
|
|
1097
|
+
# type; name it for the alphabetically first of their keys, so which field
|
|
1098
|
+
# the walk happened to reach first doesn't decide.
|
|
1099
|
+
def rename_union(union, key, taken)
|
|
1100
|
+
taken.delete(union.class_name)
|
|
1101
|
+
union.class_name = pick_name(key, taken)
|
|
1102
|
+
end
|
|
844
1103
|
end
|