graph_weaver 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
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,80 +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
|
-
|
|
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
|
|
182
245
|
|
|
183
|
-
|
|
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)"
|
|
184
248
|
end
|
|
249
|
+
private :validate_module_name!
|
|
185
250
|
|
|
186
251
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
187
252
|
|
|
188
|
-
# Names
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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.
|
|
191
256
|
RUBY_KEYWORDS = %w[
|
|
192
257
|
alias and begin break case class def defined? do else elsif end
|
|
193
258
|
ensure false for if in module next nil not or redo rescue retry
|
|
@@ -195,6 +260,17 @@ class GraphWeaver::Codegen
|
|
|
195
260
|
BEGIN END __FILE__ __LINE__ __ENCODING__
|
|
196
261
|
].to_set.freeze
|
|
197
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
|
|
198
274
|
|
|
199
275
|
def generate
|
|
200
276
|
begin
|
|
@@ -202,21 +278,14 @@ class GraphWeaver::Codegen
|
|
|
202
278
|
rescue GraphQL::ParseError => e
|
|
203
279
|
# unparseable queries wrap like invalid ones — everything raised
|
|
204
280
|
# here descends from GraphWeaver::Error
|
|
205
|
-
raise GraphWeaver::ValidationError.new([
|
|
281
|
+
raise GraphWeaver::ValidationError.new([detail(e.message, e.line, e.col)])
|
|
206
282
|
end
|
|
207
283
|
if errors.any?
|
|
208
284
|
raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
|
|
209
285
|
end
|
|
210
286
|
|
|
211
287
|
validate_registrations!
|
|
212
|
-
|
|
213
|
-
@variable_enums = {}
|
|
214
|
-
@variable_inputs = {}
|
|
215
|
-
@mapped_enums = {}
|
|
216
|
-
@used_unions = []
|
|
217
|
-
# requires the generated file needs (custom scalars, enum mappings,
|
|
218
|
-
# type helpers all contribute)
|
|
219
|
-
@requires = []
|
|
288
|
+
reset_walk_state!
|
|
220
289
|
|
|
221
290
|
operation = load_operation(@query)
|
|
222
291
|
root_type = operation_root_type(operation)
|
|
@@ -226,15 +295,42 @@ class GraphWeaver::Codegen
|
|
|
226
295
|
raise ArgumentError, "module_name: required for anonymous operations"
|
|
227
296
|
end
|
|
228
297
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
233
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)
|
|
234
332
|
variables = operation.variables.map do |var|
|
|
235
333
|
node = ast_type_ref(var.type)
|
|
236
|
-
# a variable is optional when nullable or defaulted; optional kwargs
|
|
237
|
-
# default to nil and are omitted from the wire
|
|
238
334
|
required = node.non_null? && var.default_value.nil?
|
|
239
335
|
kwarg = underscore(var.name)
|
|
240
336
|
# kwargs are declared and forwarded bare in generated source
|
|
@@ -243,22 +339,156 @@ class GraphWeaver::Codegen
|
|
|
243
339
|
"variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
|
|
244
340
|
"(a Ruby keyword) — rename the variable"
|
|
245
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
|
|
246
347
|
VarDef.new(kwarg, var.name, node, required)
|
|
247
348
|
end
|
|
248
349
|
|
|
249
|
-
|
|
350
|
+
# two variables that underscore to the same kwarg ($userId + $user_id) would
|
|
351
|
+
# silently drop one on the wire — flag it like a prop collision
|
|
352
|
+
collision = variables.group_by(&:kwarg).find { |_, vars| vars.size > 1 }
|
|
353
|
+
if collision
|
|
354
|
+
wire = collision.last.map { |var| "$#{var.wire}" }.join(", ")
|
|
355
|
+
raise GraphWeaver::Error,
|
|
356
|
+
"variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
|
|
357
|
+
end
|
|
250
358
|
|
|
251
|
-
|
|
359
|
+
variables
|
|
252
360
|
end
|
|
253
361
|
|
|
254
|
-
|
|
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
|
|
377
|
+
|
|
378
|
+
nodes
|
|
379
|
+
end
|
|
380
|
+
|
|
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
|
|
255
437
|
|
|
256
|
-
# A
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
262
492
|
def self.validate_registration!(schema, kind, name)
|
|
263
493
|
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
264
494
|
# the field exists and is a scalar, not that a type named "Type.field" exists.
|
|
@@ -270,8 +500,7 @@ class GraphWeaver::Codegen
|
|
|
270
500
|
|
|
271
501
|
return if schema.get_type(name)
|
|
272
502
|
|
|
273
|
-
suggestion =
|
|
274
|
-
DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
|
|
503
|
+
suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
|
|
275
504
|
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
276
505
|
# the type registry is reached via extend_type; scalars/enums via register_*
|
|
277
506
|
method = kind == "type" ? "extend_type" : "register_#{kind}"
|
|
@@ -294,13 +523,19 @@ class GraphWeaver::Codegen
|
|
|
294
523
|
# map — reusable fragments a query can spread. Fragment files hold only
|
|
295
524
|
# fragments (no operations); names are unique across them.
|
|
296
525
|
def self.load_fragments(paths)
|
|
297
|
-
|
|
298
|
-
|
|
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)
|
|
299
530
|
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
300
531
|
raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
|
|
301
532
|
end
|
|
302
533
|
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
303
|
-
|
|
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
|
|
304
539
|
out[frag.name] = frag
|
|
305
540
|
end
|
|
306
541
|
end
|
|
@@ -310,18 +545,34 @@ class GraphWeaver::Codegen
|
|
|
310
545
|
# shadows with a local definition of the same name — the names
|
|
311
546
|
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
312
547
|
# when they sit on a whole-union field.
|
|
313
|
-
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)
|
|
314
553
|
return [] if shared.empty?
|
|
315
554
|
|
|
316
|
-
doc = GraphQL.parse(query)
|
|
317
555
|
local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
|
|
318
556
|
reachable_fragments(fragment_spreads(doc.definitions), shared, local)
|
|
319
557
|
end
|
|
320
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
|
+
|
|
321
572
|
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
322
573
|
# the sent query is self-contained. Unused shared fragments are left out.
|
|
323
|
-
def self.inline_fragments(query, shared)
|
|
324
|
-
used = shared_fragment_spreads(query, shared)
|
|
574
|
+
def self.inline_fragments(query, shared, path = nil)
|
|
575
|
+
used = shared_fragment_spreads(query, shared, path)
|
|
325
576
|
return query if used.empty?
|
|
326
577
|
|
|
327
578
|
"#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
|
|
@@ -357,24 +608,30 @@ class GraphWeaver::Codegen
|
|
|
357
608
|
# source location, so ValidationError#errors is inspectable.
|
|
358
609
|
def validation_detail(error)
|
|
359
610
|
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
360
|
-
|
|
611
|
+
detail(error.message, loc && loc["line"], loc && loc["column"])
|
|
612
|
+
end
|
|
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: }
|
|
361
620
|
end
|
|
362
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.
|
|
363
625
|
def validate_registrations!
|
|
364
|
-
{
|
|
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|
|
|
365
631
|
registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
|
|
366
632
|
end
|
|
367
633
|
end
|
|
368
634
|
|
|
369
|
-
|
|
370
|
-
# Selection#each_field, collected by result key (codegen groups
|
|
371
|
-
# repeated selections of one field so it can merge them)
|
|
372
|
-
def gather(type, selections)
|
|
373
|
-
out = {}
|
|
374
|
-
each_field(type, selections) { |key, node| (out[key] ||= []) << node }
|
|
375
|
-
out
|
|
376
|
-
end
|
|
377
|
-
|
|
378
635
|
def object_node(type, selections, class_name)
|
|
379
636
|
node = ObjectNode.new(class_name)
|
|
380
637
|
node.graphql_type = type.graphql_name
|
|
@@ -384,10 +641,13 @@ class GraphWeaver::Codegen
|
|
|
384
641
|
# same union selected two ways (unblockOptions vs selectedOption) shares
|
|
385
642
|
# one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
|
|
386
643
|
union_cache = {}
|
|
644
|
+
props = {}
|
|
387
645
|
|
|
388
|
-
|
|
646
|
+
gather_conditional(type, selections).each do |key, occurrences|
|
|
647
|
+
field_nodes = occurrences.map(&:first)
|
|
389
648
|
field_name = field_nodes.first.name
|
|
390
649
|
prop = underscore(key)
|
|
650
|
+
check_output_prop!(type, key, prop, props)
|
|
391
651
|
|
|
392
652
|
child = if field_name == "__typename"
|
|
393
653
|
NonNull.new(scalar_node("String"))
|
|
@@ -395,59 +655,66 @@ class GraphWeaver::Codegen
|
|
|
395
655
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
396
656
|
sub_selections = field_nodes.flat_map(&:selections)
|
|
397
657
|
|
|
398
|
-
case (core = unwrap
|
|
658
|
+
case (core = field_type.unwrap).kind.name
|
|
399
659
|
when "OBJECT"
|
|
400
|
-
name = pick_name(
|
|
660
|
+
name = pick_name(key, taken)
|
|
401
661
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
402
662
|
when "UNION", "INTERFACE"
|
|
403
663
|
conditions = concrete_conditions(core, sub_selections)
|
|
404
664
|
bare = bare_fields(sub_selections) - ["__typename"]
|
|
405
665
|
|
|
406
|
-
if conditions.empty?
|
|
407
|
-
#
|
|
408
|
-
#
|
|
409
|
-
|
|
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)
|
|
410
671
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
411
672
|
elsif conditions.size == 1 && bare.empty? &&
|
|
412
673
|
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
413
674
|
# a single `... on X` condition: narrow to X's struct — nil
|
|
414
675
|
# when the runtime type doesn't match (narrowing filters).
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
# @skip/@include would
|
|
418
|
-
# from a miss ({} either
|
|
419
|
-
|
|
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)
|
|
420
683
|
raise GraphWeaver::Error,
|
|
421
684
|
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
422
|
-
"@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"
|
|
423
687
|
end
|
|
424
688
|
|
|
425
|
-
name = pick_name(
|
|
426
|
-
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
427
|
-
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)) &&
|
|
428
692
|
@hoistable_unions.include?(frag)
|
|
429
693
|
# a whole-union field spread as a named shared fragment: hoist to
|
|
430
|
-
# the shared
|
|
694
|
+
# the shared types module so the same union across queries is one
|
|
431
695
|
# Ruby type family (one exhaustive `case ... T.absurd`).
|
|
432
696
|
@used_unions << frag unless @used_unions.include?(frag)
|
|
433
697
|
ref = UnionRefNode.new(camelize(frag))
|
|
434
698
|
type_ref(field_type) { ref }
|
|
435
699
|
else
|
|
436
700
|
members = union_members(core, sub_selections)
|
|
437
|
-
|
|
438
|
-
union
|
|
439
|
-
|
|
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
|
|
440
711
|
type_ref(field_type) { union }
|
|
441
712
|
end
|
|
442
713
|
when "ENUM"
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
# sorted so output is deterministic across schema sources
|
|
448
|
-
# (SDL round-trips reorder values alphabetically)
|
|
449
|
-
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
450
|
-
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) }
|
|
451
718
|
when "SCALAR"
|
|
452
719
|
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
453
720
|
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
@@ -456,9 +723,11 @@ class GraphWeaver::Codegen
|
|
|
456
723
|
end
|
|
457
724
|
end
|
|
458
725
|
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
|
|
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) }
|
|
462
731
|
child = child.of if child.is_a?(NonNull)
|
|
463
732
|
end
|
|
464
733
|
|
|
@@ -469,79 +738,47 @@ class GraphWeaver::Codegen
|
|
|
469
738
|
node
|
|
470
739
|
end
|
|
471
740
|
|
|
472
|
-
#
|
|
473
|
-
#
|
|
474
|
-
#
|
|
475
|
-
#
|
|
476
|
-
def
|
|
477
|
-
|
|
478
|
-
|
|
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)
|
|
750
|
+
raise GraphWeaver::Error,
|
|
751
|
+
"#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
|
|
752
|
+
"already defines — alias it in the query (`#{prop}Value: #{key}`)"
|
|
479
753
|
end
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
# Registered aliases for a GraphQL type: global registry plus this client's
|
|
483
|
-
# overlay (client-scoped wins on a name clash).
|
|
484
|
-
def type_aliases(graphql_name)
|
|
485
|
-
global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
486
|
-
(global.merge(@types[graphql_name]&.dig(:aliases) || {}))
|
|
487
|
-
end
|
|
488
754
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
# Walk a dotted path (Ruby prop names) through this struct's selected fields,
|
|
492
|
-
# building the delegator expression (`meta&.tag`) and its return type. Any hop
|
|
493
|
-
# is nilable -> the accessor is nilable; a list hop or an unselected segment
|
|
494
|
-
# raises. The leaf may be any node (scalar, enum, nested struct).
|
|
495
|
-
def resolve_alias(node, name, segments)
|
|
496
|
-
if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
|
|
755
|
+
if (earlier = props[prop])
|
|
497
756
|
raise GraphWeaver::Error,
|
|
498
|
-
"
|
|
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"
|
|
499
759
|
end
|
|
500
760
|
|
|
501
|
-
|
|
502
|
-
parts = []
|
|
503
|
-
nilable = T.let(false, T::Boolean)
|
|
504
|
-
segments.each_with_index do |seg, i|
|
|
505
|
-
field = current.fields.find { |f| f.prop == seg }
|
|
506
|
-
unless field
|
|
507
|
-
props = current.fields.map(&:prop)
|
|
508
|
-
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
509
|
-
DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
|
|
510
|
-
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
511
|
-
raise GraphWeaver::Error,
|
|
512
|
-
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
513
|
-
end
|
|
514
|
-
nilable ||= !field.node.non_null?
|
|
515
|
-
|
|
516
|
-
if i == segments.size - 1
|
|
517
|
-
leaf = field.node.bare_type
|
|
518
|
-
type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
|
|
519
|
-
return ObjectNode::Alias.new(name, (parts << seg).join, type)
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
inner = T.let(field.node, T.untyped)
|
|
523
|
-
inner = inner.of while inner.is_a?(NonNull)
|
|
524
|
-
raise GraphWeaver::Error, "alias #{name.inspect}: cannot traverse list-typed '#{seg}'" if inner.is_a?(List)
|
|
525
|
-
unless inner.is_a?(ObjectNode)
|
|
526
|
-
raise GraphWeaver::Error, "alias #{name.inspect}: '#{seg}' is not an object to traverse into"
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
parts << seg << (field.node.non_null? ? "." : "&.")
|
|
530
|
-
current = inner
|
|
531
|
-
end
|
|
761
|
+
props[prop] = key
|
|
532
762
|
end
|
|
533
763
|
|
|
534
|
-
# The concrete type conditions a selection mentions
|
|
535
|
-
#
|
|
536
|
-
|
|
537
|
-
|
|
764
|
+
# The concrete type conditions a selection mentions, minus conditions naming
|
|
765
|
+
# the abstract type itself — recursing into named-fragment and inline bodies,
|
|
766
|
+
# so a `... on X` nested inside a spread (`{ ...NodeFields }` where NodeFields
|
|
767
|
+
# holds `... on X`) still drives dispatch instead of being silently dropped.
|
|
768
|
+
def concrete_conditions(core, selections, visiting = Set.new)
|
|
769
|
+
selections.flat_map do |selection|
|
|
538
770
|
case selection
|
|
539
771
|
when GraphQL::Language::Nodes::InlineFragment
|
|
540
|
-
selection.type&.name
|
|
772
|
+
[selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
|
|
541
773
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
542
|
-
|
|
774
|
+
next [] if visiting.include?(selection.name)
|
|
775
|
+
|
|
776
|
+
fragment = @fragments.fetch(selection.name)
|
|
777
|
+
[fragment.type.name, *concrete_conditions(core, fragment.selections, visiting | [selection.name])]
|
|
778
|
+
else
|
|
779
|
+
[]
|
|
543
780
|
end
|
|
544
|
-
end.uniq - [core.graphql_name]
|
|
781
|
+
end.compact.uniq - [core.graphql_name]
|
|
545
782
|
end
|
|
546
783
|
|
|
547
784
|
# result keys selected as plain fields (outside any type condition)
|
|
@@ -563,12 +800,23 @@ class GraphWeaver::Codegen
|
|
|
563
800
|
# does the flattened selection (as seen by member) include at least one
|
|
564
801
|
# field guaranteed to be present in a matching response?
|
|
565
802
|
def unconditional_field?(member, selections)
|
|
566
|
-
each_field(member, selections) do |_key, node|
|
|
567
|
-
return true if
|
|
803
|
+
each_field(member, selections) do |_key, node, conditional|
|
|
804
|
+
return true if !conditional && !conditional?(node)
|
|
568
805
|
end
|
|
569
806
|
false
|
|
570
807
|
end
|
|
571
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
|
+
|
|
572
820
|
# rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
|
|
573
821
|
# whenever the runtime type doesn't match, whatever the schema promises
|
|
574
822
|
def nilable_type_ref(type, &core)
|
|
@@ -582,29 +830,65 @@ class GraphWeaver::Codegen
|
|
|
582
830
|
end
|
|
583
831
|
end
|
|
584
832
|
|
|
585
|
-
# Abstract types (unions AND interfaces) whose selections vary by
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
# interface
|
|
589
|
-
#
|
|
590
|
-
#
|
|
591
|
-
# 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.
|
|
592
839
|
def union_members(type, selections)
|
|
593
|
-
unless
|
|
840
|
+
unless dispatchable_typename?(type, selections)
|
|
594
841
|
raise ArgumentError,
|
|
595
|
-
"select __typename on #{type.graphql_name} so the union can dispatch — " \
|
|
596
|
-
"
|
|
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)"
|
|
597
845
|
end
|
|
598
846
|
|
|
599
|
-
|
|
847
|
+
selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
|
|
600
848
|
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
601
849
|
end
|
|
602
850
|
end
|
|
603
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
|
+
|
|
604
886
|
# A name-independent structural fingerprint of a union's members, so two
|
|
605
887
|
# occurrences that generate identical structs collapse to one Ruby type.
|
|
606
|
-
def union_signature(members)
|
|
607
|
-
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(",")
|
|
608
892
|
end
|
|
609
893
|
|
|
610
894
|
# Structural signature of a node — ignores the generated class name (which
|
|
@@ -621,7 +905,7 @@ class GraphWeaver::Codegen
|
|
|
621
905
|
when ObjectNode
|
|
622
906
|
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
623
907
|
"o:#{node.graphql_type}(#{inner})"
|
|
624
|
-
when UnionNode then "u:(#{union_signature(node.members)})"
|
|
908
|
+
when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
|
|
625
909
|
when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
|
|
626
910
|
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
627
911
|
end
|
|
@@ -648,8 +932,7 @@ class GraphWeaver::Codegen
|
|
|
648
932
|
when "SCALAR"
|
|
649
933
|
scalar_node(core.graphql_name)
|
|
650
934
|
when "ENUM"
|
|
651
|
-
mapped_enum_node(core) || (@
|
|
652
|
-
EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
|
|
935
|
+
mapped_enum_node(core) || (@enums[core.graphql_name] ||= enum_node(core))
|
|
653
936
|
when "INPUT_OBJECT"
|
|
654
937
|
input_node(core)
|
|
655
938
|
else
|
|
@@ -665,40 +948,70 @@ class GraphWeaver::Codegen
|
|
|
665
948
|
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
666
949
|
|
|
667
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?
|
|
668
952
|
# sorted so output is deterministic across schema sources
|
|
669
953
|
core.arguments.values.sort_by(&:graphql_name).each do |argument|
|
|
670
954
|
prop = underscore(argument.graphql_name)
|
|
671
|
-
#
|
|
672
|
-
|
|
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)
|
|
673
960
|
raise GraphWeaver::Error,
|
|
674
961
|
"input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
|
|
675
|
-
"which collides with
|
|
962
|
+
"which collides with a method every struct defines"
|
|
676
963
|
end
|
|
677
964
|
|
|
678
|
-
child = type_ref(argument.type) { variable_core(
|
|
965
|
+
child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
|
|
679
966
|
required = child.non_null? && !argument.default_value?
|
|
680
967
|
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
681
968
|
end
|
|
682
969
|
node
|
|
683
970
|
end
|
|
684
971
|
|
|
685
|
-
# The
|
|
686
|
-
#
|
|
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
|
|
687
998
|
|
|
999
|
+
values
|
|
1000
|
+
end
|
|
688
1001
|
|
|
689
|
-
# Registered helper-module names for a GraphQL type
|
|
690
|
-
# registrations plus this client's), collecting their requires.
|
|
1002
|
+
# Registered helper-module names for a GraphQL type, collecting their requires.
|
|
691
1003
|
def type_mixins(graphql_name)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1004
|
+
entry = GraphWeaver::Codegen.type_registry[graphql_name]
|
|
1005
|
+
return [] unless entry
|
|
1006
|
+
|
|
1007
|
+
@requires.concat(entry[:requires])
|
|
1008
|
+
entry[:mixins].map(&:name)
|
|
695
1009
|
end
|
|
696
1010
|
|
|
697
1011
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
698
|
-
# mapping
|
|
699
|
-
# unregistered, falling back to a generated T::Enum.
|
|
1012
|
+
# mapping; nil when unregistered, falling back to a generated T::Enum.
|
|
700
1013
|
def mapped_enum_node(core)
|
|
701
|
-
enum_type =
|
|
1014
|
+
enum_type = GraphWeaver::Codegen.enum_registry[core.graphql_name]
|
|
702
1015
|
return unless enum_type
|
|
703
1016
|
|
|
704
1017
|
@requires.concat(enum_type.requires)
|
|
@@ -708,16 +1021,32 @@ class GraphWeaver::Codegen
|
|
|
708
1021
|
# A Scalar node, recording any requires its registered type needs so the
|
|
709
1022
|
# generated file can require them (collected across the whole query).
|
|
710
1023
|
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
711
|
-
# the scalar-name registration
|
|
1024
|
+
# the scalar-name registration.
|
|
712
1025
|
def scalar_node(name, coordinate = nil)
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
717
1032
|
@requires.concat(scalar.requires)
|
|
718
1033
|
Scalar.new(scalar)
|
|
719
1034
|
end
|
|
720
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
|
+
|
|
721
1050
|
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
722
1051
|
def type_ref(type, &core)
|
|
723
1052
|
case type.kind.name
|
|
@@ -730,21 +1059,45 @@ class GraphWeaver::Codegen
|
|
|
730
1059
|
end
|
|
731
1060
|
end
|
|
732
1061
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
737
1080
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
745
1091
|
|
|
746
|
-
taken <<
|
|
747
|
-
|
|
1092
|
+
taken << name
|
|
1093
|
+
name
|
|
748
1094
|
end
|
|
749
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
|
|
750
1103
|
end
|