graph_weaver 0.3.0 → 0.4.4
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 +88 -0
- data/CLAUDE.md +1 -2
- data/Gemfile.lock +22 -22
- data/docs/federation.md +36 -58
- data/docs/generated_modules.md +40 -4
- data/docs/getting_started.md +52 -0
- data/docs/scalars.md +65 -9
- data/graph_weaver.gemspec +3 -1
- data/lib/graph_weaver/client.rb +6 -4
- data/lib/graph_weaver/codegen/emit.rb +56 -11
- data/lib/graph_weaver/codegen/enum_type.rb +37 -5
- data/lib/graph_weaver/codegen/nodes.rb +27 -3
- data/lib/graph_weaver/codegen.rb +261 -12
- data/lib/graph_weaver/schema_loader.rb +134 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +112 -45
- metadata +3 -4
- data/lib/graph_weaver/directive_defaults_patch.rb +0 -32
|
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
|
|
|
115
115
|
names.uniq
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
def emit_shared_aliases(out, names)
|
|
118
|
+
def emit_shared_aliases(out, names, namespace = @inputs_namespace)
|
|
119
119
|
return if names.empty?
|
|
120
120
|
|
|
121
|
-
names.each { |name| out << " #{name} = #{
|
|
121
|
+
names.each { |name| out << " #{name} = #{namespace}::#{name}" }
|
|
122
122
|
out << ""
|
|
123
123
|
end
|
|
124
124
|
|
|
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
|
|
|
175
175
|
files
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# The shared unions artifact as a single file: every hoisted union as a
|
|
179
|
+
# <module_name>::<Name> module. Unions don't cross-reference (each is a
|
|
180
|
+
# self-contained fragment), so there's no need for per-type files or the
|
|
181
|
+
# forward declarations recursive input types require.
|
|
182
|
+
def emit_unions_file(unions)
|
|
183
|
+
out = []
|
|
184
|
+
out << "# typed: strict"
|
|
185
|
+
out << "# frozen_string_literal: true"
|
|
186
|
+
out << ""
|
|
187
|
+
out << "# Generated by GraphWeaver — do not edit. Shared union types for this"
|
|
188
|
+
out << "# schema (named fragments on union fields); query modules alias what they use."
|
|
189
|
+
out << ""
|
|
190
|
+
requires = @requires.uniq.sort
|
|
191
|
+
if requires.any?
|
|
192
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
193
|
+
out << ""
|
|
194
|
+
end
|
|
195
|
+
out << "module #{@module_name}"
|
|
196
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
197
|
+
unions.each do |union|
|
|
198
|
+
emit_union(union, out, 1)
|
|
199
|
+
out << ""
|
|
200
|
+
end
|
|
201
|
+
out.pop if out.last == ""
|
|
202
|
+
out << "end"
|
|
203
|
+
|
|
204
|
+
{ "unions.rb" => out.join("\n") + "\n" }
|
|
205
|
+
end
|
|
206
|
+
|
|
178
207
|
# one type per file, wrapped in the namespace so bare sibling
|
|
179
208
|
# references resolve lexically
|
|
180
209
|
def inputs_file(files, name)
|
|
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
|
|
|
200
229
|
def emit_module(root, variables)
|
|
201
230
|
flatten = flatten_input(variables)
|
|
202
231
|
aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
|
|
232
|
+
# hoisted unions the result tree references, aliased so <Name>::Type and
|
|
233
|
+
# <Name>.from_h resolve to the shared module
|
|
234
|
+
union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
|
|
203
235
|
|
|
204
236
|
out = []
|
|
205
237
|
out << "# typed: strict"
|
|
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
|
|
|
218
250
|
out << "require_relative \"inputs\""
|
|
219
251
|
out << ""
|
|
220
252
|
end
|
|
253
|
+
if union_aliases.any?
|
|
254
|
+
out << "require_relative \"unions\""
|
|
255
|
+
out << ""
|
|
256
|
+
end
|
|
221
257
|
out << "module #{@module_name}"
|
|
222
|
-
out << " extend T::Sig"
|
|
223
|
-
out << ""
|
|
258
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
224
259
|
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
225
260
|
# would terminate the heredoc early — pick a delimiter the query
|
|
226
261
|
# can't collide with
|
|
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
|
|
|
235
270
|
else
|
|
236
271
|
emit_variable_types(out)
|
|
237
272
|
end
|
|
273
|
+
emit_shared_aliases(out, union_aliases, @unions_namespace)
|
|
238
274
|
emit_nested(root, out, 1)
|
|
239
275
|
out << ""
|
|
240
276
|
emit_execute(out, variables, flatten:)
|
|
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
|
|
|
282
318
|
pad = " " * indent
|
|
283
319
|
|
|
284
320
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
285
|
-
out << "#{pad} extend T::Sig"
|
|
321
|
+
out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
|
|
286
322
|
out << "#{pad} include GraphWeaver::Hints"
|
|
287
323
|
node.mixins.each do |mixin|
|
|
288
324
|
out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
|
|
289
325
|
end
|
|
290
326
|
out << ""
|
|
291
327
|
|
|
292
|
-
|
|
328
|
+
# uniq by object identity: deduped sibling unions share one node, so the
|
|
329
|
+
# shared type is emitted once (both fields' consts already reference it).
|
|
330
|
+
node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
|
|
293
331
|
emit_nested(child, out, indent + 1)
|
|
294
332
|
out << ""
|
|
295
333
|
end
|
|
@@ -311,6 +349,14 @@ class GraphWeaver::Codegen
|
|
|
311
349
|
out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
|
|
312
350
|
out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
|
|
313
351
|
out << "#{pad} end"
|
|
352
|
+
|
|
353
|
+
# alias delegators (extend_type alias:) — typed accessors that project a
|
|
354
|
+
# selected field onto the struct, next to the honest wire data
|
|
355
|
+
node.aliases.each do |a|
|
|
356
|
+
out << ""
|
|
357
|
+
out << "#{pad} sig { returns(#{a.type}) }"
|
|
358
|
+
out << "#{pad} def #{a.name} = #{a.expr}"
|
|
359
|
+
end
|
|
314
360
|
out << "#{pad}end"
|
|
315
361
|
end
|
|
316
362
|
|
|
@@ -318,8 +364,7 @@ class GraphWeaver::Codegen
|
|
|
318
364
|
pad = " " * indent
|
|
319
365
|
|
|
320
366
|
out << "#{pad}module #{node.class_name}"
|
|
321
|
-
out << "#{pad} extend T::Sig"
|
|
322
|
-
out << ""
|
|
367
|
+
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
323
368
|
|
|
324
369
|
node.members.each_value do |member|
|
|
325
370
|
emit_object(member, out, indent + 1)
|
|
@@ -346,8 +391,7 @@ class GraphWeaver::Codegen
|
|
|
346
391
|
out << " @client = T.let(nil, T.untyped)"
|
|
347
392
|
out << ""
|
|
348
393
|
out << " class << self"
|
|
349
|
-
out << " extend T::Sig"
|
|
350
|
-
out << ""
|
|
394
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
351
395
|
out << " sig { params(client: T.untyped).void }"
|
|
352
396
|
out << " attr_writer :client"
|
|
353
397
|
out << ""
|
|
@@ -378,7 +422,8 @@ class GraphWeaver::Codegen
|
|
|
378
422
|
|
|
379
423
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
380
424
|
# `execute(...).data!` — the typed result, or a raised QueryError.
|
|
381
|
-
|
|
425
|
+
# kwargs forward via hash shorthand (key == value)
|
|
426
|
+
forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
|
|
382
427
|
|
|
383
428
|
if flatten
|
|
384
429
|
out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
|
|
@@ -102,9 +102,38 @@ class GraphWeaver::Codegen
|
|
|
102
102
|
# end
|
|
103
103
|
#
|
|
104
104
|
# Additive: repeated registrations (and client-scoped ones) stack.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
#
|
|
106
|
+
# alias: projects a (possibly nested) selected field onto a flat, typed
|
|
107
|
+
# accessor on the struct — the one derivation codegen can type itself, so
|
|
108
|
+
# it's emitted into the struct body where the field is in scope:
|
|
109
|
+
#
|
|
110
|
+
# GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
|
|
111
|
+
# GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
|
|
112
|
+
# GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
|
|
113
|
+
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
114
|
+
aliases = take_aliases(kw)
|
|
115
|
+
entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
116
|
+
add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Pull alias: out of the keyword rest and normalize it; any other keyword
|
|
120
|
+
# is a typo worth flagging rather than silently dropping.
|
|
121
|
+
def take_aliases(kw)
|
|
122
|
+
aliases = normalize_aliases(kw.delete(:alias))
|
|
123
|
+
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
124
|
+
aliases
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# { accessor => [path, segments] } from a path string (accessor named
|
|
128
|
+
# after the last segment), an array of such, or an { accessor => path } hash.
|
|
129
|
+
def normalize_aliases(input)
|
|
130
|
+
case input
|
|
131
|
+
when nil then {}
|
|
132
|
+
when String then { input.split(".").last => input.split(".") }
|
|
133
|
+
when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
|
|
134
|
+
when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
|
|
135
|
+
else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
|
|
136
|
+
end
|
|
108
137
|
end
|
|
109
138
|
|
|
110
139
|
def type_registry
|
|
@@ -113,11 +142,13 @@ class GraphWeaver::Codegen
|
|
|
113
142
|
|
|
114
143
|
# shared with Client#extend_type: build/validate the mixins and
|
|
115
144
|
# append them to a registry entry
|
|
116
|
-
def add_type_helpers(entry, graphql_name, mixins, requires, block)
|
|
145
|
+
def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
|
|
117
146
|
mixins = mixins.dup
|
|
118
147
|
mixins << helper_module(graphql_name, block) if block
|
|
119
148
|
|
|
120
|
-
|
|
149
|
+
if mixins.empty? && aliases.empty?
|
|
150
|
+
raise ArgumentError, "pass one or more helper modules, a block, or alias:"
|
|
151
|
+
end
|
|
121
152
|
mixins.each do |mixin|
|
|
122
153
|
unless mixin.is_a?(Module) && mixin.name
|
|
123
154
|
raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
|
|
@@ -126,6 +157,7 @@ class GraphWeaver::Codegen
|
|
|
126
157
|
|
|
127
158
|
entry[:mixins].concat(mixins)
|
|
128
159
|
entry[:requires].concat(Array(requires))
|
|
160
|
+
(entry[:aliases] ||= {}).merge!(aliases)
|
|
129
161
|
entry
|
|
130
162
|
end
|
|
131
163
|
|
|
@@ -144,16 +144,20 @@ class GraphWeaver::Codegen
|
|
|
144
144
|
|
|
145
145
|
class ObjectNode < Node
|
|
146
146
|
Field = Struct.new(:prop, :key, :node)
|
|
147
|
+
# a resolved alias delegator (extend_type alias:): the accessor name, the
|
|
148
|
+
# Ruby path expression it reads (meta&.tag), and its Sorbet return type
|
|
149
|
+
Alias = Struct.new(:name, :expr, :type)
|
|
147
150
|
|
|
148
151
|
attr_reader :class_name, :fields
|
|
149
|
-
# the GraphQL type this struct was generated from,
|
|
150
|
-
#
|
|
151
|
-
attr_accessor :graphql_type, :mixins
|
|
152
|
+
# the GraphQL type this struct was generated from, any registered helper
|
|
153
|
+
# modules to include, and any resolved alias delegators (see extend_type)
|
|
154
|
+
attr_accessor :graphql_type, :mixins, :aliases
|
|
152
155
|
|
|
153
156
|
def initialize(class_name)
|
|
154
157
|
@class_name = class_name
|
|
155
158
|
@fields = []
|
|
156
159
|
@mixins = []
|
|
160
|
+
@aliases = []
|
|
157
161
|
end
|
|
158
162
|
|
|
159
163
|
def bare_type = class_name
|
|
@@ -277,6 +281,26 @@ class GraphWeaver::Codegen
|
|
|
277
281
|
def nested = self
|
|
278
282
|
end
|
|
279
283
|
|
|
284
|
+
# A reference to a union hoisted into the shared unions module (a named
|
|
285
|
+
# shared fragment spread as a whole union field): the query references
|
|
286
|
+
# <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
|
|
287
|
+
# alias the query module gives GraphQLUnions::<Name>. The type family lives
|
|
288
|
+
# once in the shared module, so the same union across queries is one Ruby
|
|
289
|
+
# type — nested is nil, nothing is emitted here.
|
|
290
|
+
class UnionRefNode < Node
|
|
291
|
+
attr_reader :class_name
|
|
292
|
+
|
|
293
|
+
def initialize(class_name)
|
|
294
|
+
@class_name = class_name
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def bare_type = "#{class_name}::Type"
|
|
298
|
+
|
|
299
|
+
def cast(expr, _depth)
|
|
300
|
+
"#{class_name}.from_h(#{expr})"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
280
304
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
281
305
|
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
282
306
|
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -49,8 +49,12 @@ class GraphWeaver::Codegen
|
|
|
49
49
|
# mixin modules, each keyed by GraphQL name). inputs_namespace: is the
|
|
50
50
|
# shared-inputs workflow (see GraphWeaver.generate!): variable types
|
|
51
51
|
# live once in that module and the query module aliases what it uses.
|
|
52
|
+
# unions_namespace:/hoistable_unions: are the parallel shared-unions
|
|
53
|
+
# workflow — a whole-union field spread as a named shared fragment resolves
|
|
54
|
+
# to one canonical type in that module (see used_union_names).
|
|
52
55
|
def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
|
|
53
|
-
scalars: nil, enums: nil, types: nil, inputs_namespace: nil
|
|
56
|
+
scalars: nil, enums: nil, types: nil, inputs_namespace: nil, unions_namespace: nil,
|
|
57
|
+
hoistable_unions: nil)
|
|
54
58
|
@schema = schema
|
|
55
59
|
@query = query.strip
|
|
56
60
|
@module_name = module_name
|
|
@@ -59,6 +63,12 @@ class GraphWeaver::Codegen
|
|
|
59
63
|
@enums = enums || {}
|
|
60
64
|
@types = types || {}
|
|
61
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
|
|
70
|
+
@hoistable_unions = hoistable_unions || []
|
|
71
|
+
@used_unions = []
|
|
62
72
|
@client_const = self.class.client_const(client)
|
|
63
73
|
|
|
64
74
|
if client && @client_const.nil?
|
|
@@ -111,6 +121,11 @@ class GraphWeaver::Codegen
|
|
|
111
121
|
{ inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
|
|
112
122
|
end
|
|
113
123
|
|
|
124
|
+
# The shared union fragments this query hoisted, by name — the generate!
|
|
125
|
+
# workflow unions these across queries to decide what the shared unions
|
|
126
|
+
# module must contain.
|
|
127
|
+
def used_union_names = @used_unions.dup
|
|
128
|
+
|
|
114
129
|
# The shared inputs artifact: the named input/enum types — plus
|
|
115
130
|
# everything they transitively reference — emitted once per schema as
|
|
116
131
|
# a manifest (inputs.rb) plus one file per type under inputs/, so a
|
|
@@ -138,6 +153,36 @@ class GraphWeaver::Codegen
|
|
|
138
153
|
emit_inputs_files
|
|
139
154
|
end
|
|
140
155
|
|
|
156
|
+
# The shared unions artifact: each named shared fragment a query hoisted,
|
|
157
|
+
# built once against the schema as <module_name>::<Name>, so the same union
|
|
158
|
+
# across queries resolves to one Ruby type family. `fragments` is the loaded
|
|
159
|
+
# shared-fragment table (nested spreads resolve through it); `names` the
|
|
160
|
+
# fragments to build. Returns { "unions.rb" => source }.
|
|
161
|
+
def self.generate_unions(schema:, module_name:, fragments:, names:,
|
|
162
|
+
scalars: nil, enums: nil, types: nil)
|
|
163
|
+
codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
|
|
164
|
+
codegen.generate_unions(fragments, names)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def generate_unions(fragments, names)
|
|
168
|
+
unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
169
|
+
raise ArgumentError, "unions module name must be a constant name, got #{@module_name.inspect}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
@requires = []
|
|
173
|
+
@mapped_enums = {}
|
|
174
|
+
# nested spreads inside a shared fragment resolve through the whole table
|
|
175
|
+
@fragments = fragments
|
|
176
|
+
|
|
177
|
+
unions = names.uniq.sort.map do |name|
|
|
178
|
+
fragment = fragments.fetch(name)
|
|
179
|
+
type = @schema.get_type(fragment.type.name)
|
|
180
|
+
UnionNode.new(camelize(name), union_members(type, fragment.selections))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
emit_unions_file(unions)
|
|
184
|
+
end
|
|
185
|
+
|
|
141
186
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
142
187
|
|
|
143
188
|
# Names that cannot appear bare in generated Ruby: keywords aren't
|
|
@@ -168,6 +213,7 @@ class GraphWeaver::Codegen
|
|
|
168
213
|
@variable_enums = {}
|
|
169
214
|
@variable_inputs = {}
|
|
170
215
|
@mapped_enums = {}
|
|
216
|
+
@used_unions = []
|
|
171
217
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
172
218
|
# type helpers all contribute)
|
|
173
219
|
@requires = []
|
|
@@ -214,6 +260,14 @@ class GraphWeaver::Codegen
|
|
|
214
260
|
# whose schema introspects lazily). Global registrations skip this:
|
|
215
261
|
# they may target a different client's server.
|
|
216
262
|
def self.validate_registration!(schema, kind, name)
|
|
263
|
+
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
264
|
+
# the field exists and is a scalar, not that a type named "Type.field" exists.
|
|
265
|
+
if kind == "scalar" && name.include?(".")
|
|
266
|
+
return if scalar_field?(schema, name)
|
|
267
|
+
|
|
268
|
+
raise GraphWeaver::Error, "register_scalar(#{name.inspect}) matches no scalar field in this schema"
|
|
269
|
+
end
|
|
270
|
+
|
|
217
271
|
return if schema.get_type(name)
|
|
218
272
|
|
|
219
273
|
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
@@ -224,6 +278,81 @@ class GraphWeaver::Codegen
|
|
|
224
278
|
raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
|
|
225
279
|
end
|
|
226
280
|
|
|
281
|
+
# Whether `coordinate` ("Type.field") names an existing scalar field — the
|
|
282
|
+
# validation for a per-field register_scalar override.
|
|
283
|
+
def self.scalar_field?(schema, coordinate)
|
|
284
|
+
type_name, field_name = coordinate.split(".", 2)
|
|
285
|
+
return false unless field_name
|
|
286
|
+
|
|
287
|
+
field = schema.get_field(type_name, field_name)
|
|
288
|
+
!!field && field.type.unwrap.kind.name == "SCALAR"
|
|
289
|
+
rescue StandardError
|
|
290
|
+
false
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Parse every fragment file under `paths` into one { name => FragmentDefinition }
|
|
294
|
+
# map — reusable fragments a query can spread. Fragment files hold only
|
|
295
|
+
# fragments (no operations); names are unique across them.
|
|
296
|
+
def self.load_fragments(paths)
|
|
297
|
+
Array(paths).flat_map { |dir| Dir[File.join(dir, "*.graphql")].sort }.each_with_object({}) do |file, out|
|
|
298
|
+
doc = GraphQL.parse(File.read(file))
|
|
299
|
+
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
300
|
+
raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
|
|
301
|
+
end
|
|
302
|
+
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
303
|
+
raise GraphWeaver::Error, "duplicate shared fragment '#{frag.name}' (#{file})" if out.key?(frag.name)
|
|
304
|
+
out[frag.name] = frag
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The shared fragments a query spreads (transitively), excluding any it
|
|
310
|
+
# shadows with a local definition of the same name — the names
|
|
311
|
+
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
312
|
+
# when they sit on a whole-union field.
|
|
313
|
+
def self.shared_fragment_spreads(query, shared)
|
|
314
|
+
return [] if shared.empty?
|
|
315
|
+
|
|
316
|
+
doc = GraphQL.parse(query)
|
|
317
|
+
local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
|
|
318
|
+
reachable_fragments(fragment_spreads(doc.definitions), shared, local)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
322
|
+
# 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)
|
|
325
|
+
return query if used.empty?
|
|
326
|
+
|
|
327
|
+
"#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# BFS over spreads, following shared fragments into their own spreads; a
|
|
331
|
+
# locally-defined or unknown spread is left for schema validation to judge.
|
|
332
|
+
def self.reachable_fragments(spreads, shared, local)
|
|
333
|
+
needed = []
|
|
334
|
+
queue = spreads.dup
|
|
335
|
+
until queue.empty?
|
|
336
|
+
name = queue.shift
|
|
337
|
+
next if needed.include?(name) || local.include?(name) || !shared.key?(name)
|
|
338
|
+
|
|
339
|
+
needed << name
|
|
340
|
+
queue.concat(fragment_spreads([shared.fetch(name)]))
|
|
341
|
+
end
|
|
342
|
+
needed
|
|
343
|
+
end
|
|
344
|
+
private_class_method :reachable_fragments
|
|
345
|
+
|
|
346
|
+
# Names of every fragment spread reachable in these AST nodes.
|
|
347
|
+
def self.fragment_spreads(nodes, acc = [])
|
|
348
|
+
nodes.each do |node|
|
|
349
|
+
acc << node.name if node.is_a?(GraphQL::Language::Nodes::FragmentSpread)
|
|
350
|
+
fragment_spreads(node.selections, acc) if node.respond_to?(:selections)
|
|
351
|
+
end
|
|
352
|
+
acc
|
|
353
|
+
end
|
|
354
|
+
private_class_method :fragment_spreads
|
|
355
|
+
|
|
227
356
|
# Structured shape for a schema-validation error: message plus its first
|
|
228
357
|
# source location, so ValidationError#errors is inspectable.
|
|
229
358
|
def validation_detail(error)
|
|
@@ -251,6 +380,10 @@ class GraphWeaver::Codegen
|
|
|
251
380
|
node.graphql_type = type.graphql_name
|
|
252
381
|
node.mixins = type_mixins(type.graphql_name)
|
|
253
382
|
taken = [class_name]
|
|
383
|
+
# Dedup structurally-identical dispatch-union fields on this struct: the
|
|
384
|
+
# same union selected two ways (unblockOptions vs selectedOption) shares
|
|
385
|
+
# one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
|
|
386
|
+
union_cache = {}
|
|
254
387
|
|
|
255
388
|
gather(type, selections).each do |key, field_nodes|
|
|
256
389
|
field_name = field_nodes.first.name
|
|
@@ -291,9 +424,20 @@ class GraphWeaver::Codegen
|
|
|
291
424
|
|
|
292
425
|
name = pick_name(member.graphql_name, key, taken)
|
|
293
426
|
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
427
|
+
elsif @unions_namespace && (frag = lone_shared_spread(sub_selections)) &&
|
|
428
|
+
@hoistable_unions.include?(frag)
|
|
429
|
+
# a whole-union field spread as a named shared fragment: hoist to
|
|
430
|
+
# the shared unions module so the same union across queries is one
|
|
431
|
+
# Ruby type family (one exhaustive `case ... T.absurd`).
|
|
432
|
+
@used_unions << frag unless @used_unions.include?(frag)
|
|
433
|
+
ref = UnionRefNode.new(camelize(frag))
|
|
434
|
+
type_ref(field_type) { ref }
|
|
294
435
|
else
|
|
295
|
-
|
|
296
|
-
|
|
436
|
+
members = union_members(core, sub_selections)
|
|
437
|
+
# reuse an identical sibling union (pick_name/name only on a miss)
|
|
438
|
+
union = (union_cache[union_signature(members)] ||=
|
|
439
|
+
UnionNode.new(pick_name(core.graphql_name, key, taken), members))
|
|
440
|
+
type_ref(field_type) { union }
|
|
297
441
|
end
|
|
298
442
|
when "ENUM"
|
|
299
443
|
if (mapped = mapped_enum_node(core))
|
|
@@ -305,7 +449,8 @@ class GraphWeaver::Codegen
|
|
|
305
449
|
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
306
450
|
end
|
|
307
451
|
when "SCALAR"
|
|
308
|
-
|
|
452
|
+
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
453
|
+
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
309
454
|
else
|
|
310
455
|
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
311
456
|
end
|
|
@@ -320,9 +465,72 @@ class GraphWeaver::Codegen
|
|
|
320
465
|
node.fields << ObjectNode::Field.new(prop, key, child)
|
|
321
466
|
end
|
|
322
467
|
|
|
468
|
+
node.aliases = resolve_aliases(node)
|
|
323
469
|
node
|
|
324
470
|
end
|
|
325
471
|
|
|
472
|
+
# Resolve each registered alias (extend_type alias:) for this struct's type
|
|
473
|
+
# against its actual selection — path -> a typed delegator emitted into the
|
|
474
|
+
# struct body. Validated here, per query, so an unselected or untraversable
|
|
475
|
+
# path fails at generation with a pointed message.
|
|
476
|
+
def resolve_aliases(node)
|
|
477
|
+
type_aliases(node.graphql_type).map do |name, segments|
|
|
478
|
+
resolve_alias(node, name, segments)
|
|
479
|
+
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
|
+
|
|
489
|
+
ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
|
|
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)
|
|
497
|
+
raise GraphWeaver::Error,
|
|
498
|
+
"alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
current = T.let(node, T.untyped)
|
|
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
|
|
532
|
+
end
|
|
533
|
+
|
|
326
534
|
# The concrete type conditions a selection mentions (inline fragments
|
|
327
535
|
# and named spreads), minus conditions naming the abstract type itself.
|
|
328
536
|
def concrete_conditions(core, selections)
|
|
@@ -341,6 +549,17 @@ class GraphWeaver::Codegen
|
|
|
341
549
|
selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
|
|
342
550
|
end
|
|
343
551
|
|
|
552
|
+
# The fragment name when a selection is exactly one bare fragment spread
|
|
553
|
+
# (`{ ...F }`) — the shape a union field must have to hoist into the shared
|
|
554
|
+
# unions module. A spread carrying directives (@skip/@include), or mixed with
|
|
555
|
+
# other fields, stays a locally-emitted union.
|
|
556
|
+
def lone_shared_spread(selections)
|
|
557
|
+
return unless selections.size == 1
|
|
558
|
+
|
|
559
|
+
spread = selections.first
|
|
560
|
+
spread.name if spread.is_a?(GraphQL::Language::Nodes::FragmentSpread) && spread.directives.empty?
|
|
561
|
+
end
|
|
562
|
+
|
|
344
563
|
# does the flattened selection (as seen by member) include at least one
|
|
345
564
|
# field guaranteed to be present in a matching response?
|
|
346
565
|
def unconditional_field?(member, selections)
|
|
@@ -367,19 +586,45 @@ class GraphWeaver::Codegen
|
|
|
367
586
|
# concrete type: one member struct per possible type; wire dispatch
|
|
368
587
|
# reads __typename, so the query must select it. For interfaces, the
|
|
369
588
|
# interface's own field selections gather into every member.
|
|
370
|
-
|
|
589
|
+
# The union's member structs (graphql type name => ObjectNode), sorted for
|
|
590
|
+
# deterministic output. Dispatch reads __typename, so the query must select
|
|
591
|
+
# it; for interfaces the interface-level fields gather into every member.
|
|
592
|
+
def union_members(type, selections)
|
|
371
593
|
unless gather(type, selections).key?("__typename")
|
|
372
594
|
raise ArgumentError,
|
|
373
|
-
"select __typename on #{type.graphql_name} so
|
|
595
|
+
"select __typename on #{type.graphql_name} so the union can dispatch — " \
|
|
374
596
|
"or narrow to a single `... on Type` condition (no dispatch needed)"
|
|
375
597
|
end
|
|
376
598
|
|
|
377
|
-
|
|
378
|
-
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
599
|
+
@schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
379
600
|
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
380
601
|
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# A name-independent structural fingerprint of a union's members, so two
|
|
605
|
+
# occurrences that generate identical structs collapse to one Ruby type.
|
|
606
|
+
def union_signature(members)
|
|
607
|
+
members.map { |gname, member| "#{gname}=#{signature(member)}" }.sort.join(",")
|
|
608
|
+
end
|
|
381
609
|
|
|
382
|
-
|
|
610
|
+
# Structural signature of a node — ignores the generated class name (which
|
|
611
|
+
# varies per occurrence), keying on GraphQL type, selection shape, and
|
|
612
|
+
# nullability so only genuinely-identical shapes collapse.
|
|
613
|
+
def signature(node)
|
|
614
|
+
case node
|
|
615
|
+
when NonNull then "!#{signature(node.of)}"
|
|
616
|
+
when List then "[#{signature(node.of)}]"
|
|
617
|
+
when NarrowedNode then "?#{signature(node.nested)}"
|
|
618
|
+
when Scalar then "s:#{node.bare_type}"
|
|
619
|
+
when EnumNode then "e:#{node.values.sort.join("|")}"
|
|
620
|
+
when MappedEnum then "m:#{node.graphql_name}"
|
|
621
|
+
when ObjectNode
|
|
622
|
+
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
623
|
+
"o:#{node.graphql_type}(#{inner})"
|
|
624
|
+
when UnionNode then "u:(#{union_signature(node.members)})"
|
|
625
|
+
when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
|
|
626
|
+
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
627
|
+
end
|
|
383
628
|
end
|
|
384
629
|
|
|
385
630
|
# Build a node from an AST type reference (variable definitions), where
|
|
@@ -462,9 +707,13 @@ class GraphWeaver::Codegen
|
|
|
462
707
|
|
|
463
708
|
# A Scalar node, recording any requires its registered type needs so the
|
|
464
709
|
# generated file can require them (collected across the whole query).
|
|
465
|
-
# Resolution:
|
|
466
|
-
|
|
467
|
-
|
|
710
|
+
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
711
|
+
# the scalar-name registration — each checked client-scoped, then global.
|
|
712
|
+
def scalar_node(name, coordinate = nil)
|
|
713
|
+
scalar =
|
|
714
|
+
(coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
|
|
715
|
+
@scalars[name.to_s] ||
|
|
716
|
+
GraphWeaver::Codegen.scalar(name)
|
|
468
717
|
@requires.concat(scalar.requires)
|
|
469
718
|
Scalar.new(scalar)
|
|
470
719
|
end
|