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