graph_weaver 0.2.0 → 0.2.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.
@@ -1,11 +1,29 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "forwardable"
5
+
4
6
  # The typed intermediate representation of a query selection: one node
5
7
  # per GraphQL type shape, each knowing its Sorbet prop type and the
6
8
  # cast/serialize code to emit.
7
9
  class GraphWeaver::Codegen
8
- class Scalar
10
+ # Protocol defaults — subclasses override what differs. The full node
11
+ # protocol: bare_type, prop_type, cast(expr, depth), identity?,
12
+ # serialize(expr, depth), serialize_identity?, coerce?, coerce(expr),
13
+ # coerce_input_type, hash_coerce(expr, depth), hash_coerce_identity?,
14
+ # non_null?, nested.
15
+ class Node
16
+ def bare_type = raise(GraphWeaver::Error, "#{self.class} must define bare_type")
17
+ def prop_type = "T.nilable(#{bare_type})"
18
+ def identity? = false
19
+ def serialize_identity? = false
20
+ def coerce? = false
21
+ def hash_coerce_identity? = false
22
+ def non_null? = false
23
+ def nested = nil
24
+ end
25
+
26
+ class Scalar < Node
9
27
  # takes a resolved ScalarType — the generator picks it from the
10
28
  # client-scoped overlay or the global registry
11
29
  def initialize(scalar_type)
@@ -52,34 +70,27 @@ class GraphWeaver::Codegen
52
70
  end
53
71
 
54
72
  def hash_coerce_identity? = !@scalar.coerce?
55
-
56
- def non_null? = false
57
- def nested = nil
58
73
  end
59
74
 
60
- class NonNull
75
+ # NonNull is its inner node with the nilability stripped — everything
76
+ # else passes through.
77
+ class NonNull < Node
78
+ extend Forwardable
79
+
61
80
  attr_reader :of
62
81
 
82
+ def_delegators :@of, :bare_type, :cast, :identity?, :serialize, :serialize_identity?,
83
+ :coerce?, :coerce, :coerce_input_type, :hash_coerce, :hash_coerce_identity?, :nested
84
+
63
85
  def initialize(of)
64
86
  @of = of
65
87
  end
66
88
 
67
- def bare_type = @of.bare_type
68
89
  def prop_type = bare_type
69
- def cast(expr, depth) = @of.cast(expr, depth)
70
- def identity? = @of.identity?
71
- def serialize(expr, depth) = @of.serialize(expr, depth)
72
- def serialize_identity? = @of.serialize_identity?
73
- def coerce? = @of.coerce?
74
- def coerce(expr) = @of.coerce(expr)
75
- def coerce_input_type = @of.coerce_input_type
76
- def hash_coerce(expr, depth) = @of.hash_coerce(expr, depth)
77
- def hash_coerce_identity? = @of.hash_coerce_identity?
78
90
  def non_null? = true
79
- def nested = @of.nested
80
91
  end
81
92
 
82
- class List
93
+ class List < Node
83
94
  attr_reader :of
84
95
 
85
96
  def initialize(of)
@@ -90,10 +101,6 @@ class GraphWeaver::Codegen
90
101
  "T::Array[#{@of.prop_type}]"
91
102
  end
92
103
 
93
- def prop_type
94
- "T.nilable(#{bare_type})"
95
- end
96
-
97
104
  def cast(expr, depth)
98
105
  var = "v#{depth}"
99
106
  element = if @of.non_null? || @of.identity?
@@ -119,7 +126,6 @@ class GraphWeaver::Codegen
119
126
  end
120
127
 
121
128
  def serialize_identity? = @of.serialize_identity?
122
- def coerce? = false
123
129
 
124
130
  def hash_coerce(expr, depth)
125
131
  var = "v#{depth}"
@@ -133,11 +139,10 @@ class GraphWeaver::Codegen
133
139
  end
134
140
 
135
141
  def hash_coerce_identity? = @of.hash_coerce_identity?
136
- def non_null? = false
137
142
  def nested = @of.nested
138
143
  end
139
144
 
140
- class ObjectNode
145
+ class ObjectNode < Node
141
146
  Field = Struct.new(:prop, :key, :node)
142
147
 
143
148
  attr_reader :class_name, :fields
@@ -153,20 +158,14 @@ class GraphWeaver::Codegen
153
158
 
154
159
  def bare_type = class_name
155
160
 
156
- def prop_type
157
- "T.nilable(#{bare_type})"
158
- end
159
-
160
161
  def cast(expr, _depth)
161
162
  "#{class_name}.from_h(#{expr})"
162
163
  end
163
164
 
164
- def identity? = false
165
- def non_null? = false
166
165
  def nested = self
167
166
  end
168
167
 
169
- class EnumNode
168
+ class EnumNode < Node
170
169
  attr_reader :class_name, :values
171
170
 
172
171
  def initialize(class_name, values)
@@ -176,22 +175,14 @@ class GraphWeaver::Codegen
176
175
 
177
176
  def bare_type = class_name
178
177
 
179
- def prop_type
180
- "T.nilable(#{bare_type})"
181
- end
182
-
183
178
  def cast(expr, _depth)
184
179
  "#{class_name}.deserialize(#{expr})"
185
180
  end
186
181
 
187
- def identity? = false
188
-
189
182
  def serialize(expr, _depth)
190
183
  "#{expr}.serialize"
191
184
  end
192
185
 
193
- def serialize_identity? = false
194
-
195
186
  # enums always coerce: a kwarg or hash field accepts the T::Enum or
196
187
  # its wire value (deserialize raises on anything else)
197
188
  def coerce? = true
@@ -202,8 +193,6 @@ class GraphWeaver::Codegen
202
193
 
203
194
  def coerce_input_type = "T.any(#{class_name}, String)"
204
195
  def hash_coerce(expr, _depth) = coerce(expr)
205
- def hash_coerce_identity? = false
206
- def non_null? = false
207
196
  def nested = self
208
197
  end
209
198
 
@@ -211,7 +200,7 @@ class GraphWeaver::Codegen
211
200
  # generated enum class — instead module-level <NAME>_FROM_WIRE /
212
201
  # <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
213
202
  # casting absorb unknown wire values (inputs stay strict).
214
- class MappedEnum
203
+ class MappedEnum < Node
215
204
  attr_reader :graphql_name, :mapping
216
205
 
217
206
  def initialize(enum_type, wire_values)
@@ -226,10 +215,6 @@ class GraphWeaver::Codegen
226
215
 
227
216
  def bare_type = @type_name
228
217
 
229
- def prop_type
230
- "T.nilable(#{bare_type})"
231
- end
232
-
233
218
  def cast(expr, _depth)
234
219
  if @fallback
235
220
  "#{const_prefix}_FROM_WIRE.fetch(#{expr}) { #{fallback_const} }"
@@ -238,14 +223,10 @@ class GraphWeaver::Codegen
238
223
  end
239
224
  end
240
225
 
241
- def identity? = false
242
-
243
226
  def serialize(expr, _depth)
244
227
  "#{const_prefix}_TO_WIRE.fetch(#{expr})"
245
228
  end
246
229
 
247
- def serialize_identity? = false
248
-
249
230
  # kwargs and hash fields accept the member or its wire value; unlike
250
231
  # casting, bad input raises even with a fallback (a typo'd input is
251
232
  # our bug, not server drift)
@@ -257,9 +238,6 @@ class GraphWeaver::Codegen
257
238
 
258
239
  def coerce_input_type = "T.any(#{@type_name}, String)"
259
240
  def hash_coerce(expr, _depth) = coerce(expr)
260
- def hash_coerce_identity? = false
261
- def non_null? = false
262
- def nested = nil
263
241
  end
264
242
 
265
243
  # A single-condition narrowing of an abstract field (`... on Pet { ... }`
@@ -267,7 +245,7 @@ class GraphWeaver::Codegen
267
245
  # nil when it doesn't — a non-match's response object carries no
268
246
  # matching fields, so the hash arrives empty. Always nilable, whatever
269
247
  # the schema's nullability, because narrowing filters.
270
- class NarrowedNode
248
+ class NarrowedNode < Node
271
249
  def initialize(of)
272
250
  @of = of
273
251
  end
@@ -275,20 +253,14 @@ class GraphWeaver::Codegen
275
253
  def class_name = @of.class_name
276
254
  def bare_type = @of.bare_type
277
255
 
278
- def prop_type
279
- "T.nilable(#{bare_type})"
280
- end
281
-
282
256
  def cast(expr, depth)
283
257
  "(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
284
258
  end
285
259
 
286
- def identity? = false
287
- def non_null? = false
288
260
  def nested = @of
289
261
  end
290
262
 
291
- class UnionNode
263
+ class UnionNode < Node
292
264
  attr_reader :class_name, :members # graphql type name => ObjectNode
293
265
 
294
266
  def initialize(class_name, members)
@@ -298,16 +270,10 @@ class GraphWeaver::Codegen
298
270
 
299
271
  def bare_type = "#{class_name}::Type"
300
272
 
301
- def prop_type
302
- "T.nilable(#{bare_type})"
303
- end
304
-
305
273
  def cast(expr, _depth)
306
274
  "#{class_name}.from_h(#{expr})"
307
275
  end
308
276
 
309
- def identity? = false
310
- def non_null? = false
311
277
  def nested = self
312
278
  end
313
279
 
@@ -315,7 +281,7 @@ class GraphWeaver::Codegen
315
281
  # serialize produces the wire hash. Inputs never cast FROM the wire.
316
282
  # Joins the coerce protocol so execute kwargs accept plain hashes,
317
283
  # normalized (and type-checked) through the generated .coerce.
318
- class InputNode
284
+ class InputNode < Node
319
285
  Field = Struct.new(:prop, :wire, :node, :required)
320
286
 
321
287
  attr_reader :class_name, :fields
@@ -327,18 +293,12 @@ class GraphWeaver::Codegen
327
293
 
328
294
  def bare_type = class_name
329
295
 
330
- def prop_type
331
- "T.nilable(#{bare_type})"
332
- end
333
-
334
296
  def serialize(expr, _depth)
335
297
  "#{expr}.serialize"
336
298
  end
337
299
 
338
- def serialize_identity? = false
339
-
340
300
  def cast(_expr, _depth)
341
- raise NotImplementedError, "input objects are never cast from responses"
301
+ raise GraphWeaver::Error, "input objects are never cast from responses"
342
302
  end
343
303
 
344
304
  def coerce? = true
@@ -347,10 +307,5 @@ class GraphWeaver::Codegen
347
307
 
348
308
  # building a struct field from a caller-supplied plain hash value
349
309
  def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
350
- def hash_coerce_identity? = false
351
-
352
- def identity? = false
353
- def non_null? = false
354
- def nested = nil
355
310
  end
356
311
  end
@@ -19,6 +19,7 @@ require "sorbet-runtime"
19
19
  # codegen/nodes.rb (the typed IR), codegen/emit.rb (source emission);
20
20
  # this file holds the public API and the query walk.
21
21
  require_relative "hints"
22
+ require_relative "input_struct"
22
23
  require_relative "inflect"
23
24
  require_relative "selection"
24
25
  require_relative "codegen/enum_type"
@@ -45,9 +46,11 @@ class GraphWeaver::Codegen
45
46
  # generation stays strict — a checked-in file deserves a deliberate
46
47
  # name). scalars:/enums:/types: are client-scoped overlays consulted
47
48
  # before the global registries (ScalarType, EnumType, and arrays of
48
- # mixin modules, each keyed by GraphQL name).
49
+ # mixin modules, each keyed by GraphQL name). inputs_namespace: is the
50
+ # shared-inputs workflow (see GraphWeaver.generate!): variable types
51
+ # live once in that module and the query module aliases what it uses.
49
52
  def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
50
- scalars: nil, enums: nil, types: nil)
53
+ scalars: nil, enums: nil, types: nil, inputs_namespace: nil)
51
54
  @schema = schema
52
55
  @query = query.strip
53
56
  @module_name = module_name
@@ -55,6 +58,7 @@ class GraphWeaver::Codegen
55
58
  @scalars = scalars || {}
56
59
  @enums = enums || {}
57
60
  @types = types || {}
61
+ @inputs_namespace = inputs_namespace
58
62
  @client_const = self.class.client_const(client)
59
63
 
60
64
  if client && @client_const.nil?
@@ -100,6 +104,40 @@ class GraphWeaver::Codegen
100
104
  mod
101
105
  end
102
106
 
107
+ # The schema-level variable types this query touched, by GraphQL
108
+ # name — the generate! workflow unions these across queries to decide
109
+ # what the shared inputs module must contain.
110
+ def variable_type_names
111
+ { inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
112
+ end
113
+
114
+ # The shared inputs artifact: the named input/enum types — plus
115
+ # everything they transitively reference — emitted once per schema as
116
+ # a manifest (inputs.rb) plus one file per type under inputs/, so a
117
+ # schema migration diffs only the types it touched. Returns
118
+ # { relative_filename => source }.
119
+ def self.generate_inputs(schema:, module_name:, input_types: [], enum_types: [],
120
+ scalars: nil, enums: nil, types: nil)
121
+ codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
122
+ codegen.generate_inputs(input_types, enum_types)
123
+ end
124
+
125
+ def generate_inputs(input_types, enum_types)
126
+ unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
127
+ raise ArgumentError, "inputs module name must be a constant name, got #{@module_name.inspect}"
128
+ end
129
+
130
+ @variable_enums = {}
131
+ @variable_inputs = {}
132
+ @mapped_enums = {}
133
+ @requires = []
134
+
135
+ enum_types.sort.each { |name| variable_core(@schema.get_type(name)) }
136
+ input_types.sort.each { |name| input_node(@schema.get_type(name)) }
137
+
138
+ emit_inputs_files
139
+ end
140
+
103
141
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
104
142
 
105
143
  # Names that cannot appear bare in generated Ruby: keywords aren't
@@ -164,61 +202,7 @@ class GraphWeaver::Codegen
164
202
 
165
203
  root = object_node(root_type, operation.selections, "Result")
166
204
 
167
- out = []
168
- out << "# typed: strict"
169
- out << "# frozen_string_literal: true"
170
- out << ""
171
- out << "# Generated by GraphWeaver — do not edit."
172
- out << ""
173
- requires = @requires.uniq.sort
174
- if requires.any?
175
- requires.each { |req| out << "require #{req.inspect}" }
176
- out << ""
177
- end
178
- out << "module #{@module_name}"
179
- out << " extend T::Sig"
180
- out << ""
181
- # a GraphQL block string could contain a bare GRAPHQL line, which
182
- # would terminate the heredoc early — pick a delimiter the query
183
- # can't collide with
184
- delimiter = "GRAPHQL"
185
- delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
186
- out << " QUERY = T.let(<<~'#{delimiter}', String)"
187
- @query.each_line { |line| out << " #{line}".rstrip }
188
- out << " #{delimiter}"
189
- out << ""
190
- @mapped_enums.each_value do |mapped|
191
- emit_mapped_enum(mapped, out, 1)
192
- out << ""
193
- end
194
- @variable_enums.each_value do |enum|
195
- emit_enum(enum, out, 1)
196
- out << ""
197
- end
198
- inputs, cyclic = ordered_inputs
199
- if cyclic
200
- # Recursive input types (Hasura bool_exp et al) reference each other,
201
- # so no definition order satisfies the runtime — forward-declare every
202
- # class empty, then let the full definitions below reopen with props.
203
- # eval'd so srb sees only the full bodies (reopening a T::Struct to
204
- # add props is a static error; adding them at runtime is fine).
205
- out << " # runtime-only forward declarations: these input types reference"
206
- out << " # each other, so the full definitions below need the constants"
207
- out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
208
- inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
209
- out << " RUBY"
210
- out << ""
211
- end
212
- inputs.each do |input|
213
- emit_input(input, out, 1)
214
- out << ""
215
- end
216
- emit_nested(root, out, 1)
217
- out << ""
218
- emit_execute(out, variables, flatten: flatten_input(variables))
219
- out << "end"
220
-
221
- out.join("\n") + "\n"
205
+ emit_module(root, variables)
222
206
  end
223
207
 
224
208
  private
@@ -251,20 +235,6 @@ class GraphWeaver::Codegen
251
235
  end
252
236
  end
253
237
 
254
- # The Relay convention — an operation whose only variable is a required
255
- # input object — reads better flattened: the input's fields become
256
- # execute's kwargs directly, and the wrapping level is rebuilt on the
257
- # wire. Multi-variable (or nullable-input) operations keep the
258
- # variable-per-kwarg surface.
259
- def flatten_input(variables)
260
- return unless variables.size == 1
261
-
262
- var = variables.first
263
- return unless var.required && var.node.is_a?(NonNull)
264
-
265
- input = var.node.of
266
- input if input.is_a?(InputNode)
267
- end
268
238
 
269
239
  # Selection#each_field, collected by result key (codegen groups
270
240
  # repeated selections of one field so it can merge them)
@@ -467,40 +437,7 @@ class GraphWeaver::Codegen
467
437
 
468
438
  # The InputNodes a struct's fields reference, through NON_NULL/LIST
469
439
  # wrappers — the edges of the input dependency graph.
470
- def input_references(node)
471
- node.fields.filter_map do |field|
472
- child = field.node
473
- child = child.of while child.respond_to?(:of)
474
- child if child.is_a?(InputNode)
475
- end
476
- end
477
-
478
- # Input structs in dependency order (referenced types before their
479
- # referrers) so each emitted const names an already-defined class.
480
- # Cycles make that impossible — flagged so emission can forward-declare
481
- # every input class first, then reopen each to add its props.
482
- def ordered_inputs
483
- ordered = []
484
- seen = {} # node => :done | :visiting (bool_exp graphs get big)
485
- cyclic = T.let(false, T::Boolean)
486
-
487
- visit = lambda do |node|
488
- next if seen[node] == :done
489
-
490
- if seen[node] == :visiting
491
- cyclic = true
492
- next
493
- end
494
-
495
- seen[node] = :visiting
496
- input_references(node).each(&visit)
497
- seen[node] = :done
498
- ordered << node
499
- end
500
- @variable_inputs.each_value(&visit)
501
440
 
502
- [ordered, cyclic]
503
- end
504
441
 
505
442
  # Registered helper-module names for a GraphQL type (additive: global
506
443
  # registrations plus this client's), collecting their requires.
@@ -0,0 +1,59 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require_relative "hints"
7
+
8
+ module GraphWeaver
9
+ # Runtime for generated input structs. Each struct declares its typed
10
+ # consts plus a compact FIELDS table — (prop, wire name, requiredness,
11
+ # serializer, coercer) per field, with the conversions emitted as
12
+ # lambdas — and this module is the loop that drives it. One copy here
13
+ # instead of unrolled methods in every struct, which is the difference
14
+ # between ~2 lines and ~6 lines per field when a Hasura bool_exp pulls
15
+ # hundreds of input types into one module.
16
+ module InputStruct
17
+ include Kernel # for sorbet: hosts are T::Structs
18
+
19
+ # serializer/coercer are code-as-data from the generated file; nil
20
+ # means identity (the wire value passes through untouched)
21
+ Field = Struct.new(:prop, :wire, :required, :serializer, :coercer)
22
+
23
+ def self.included(base)
24
+ base.extend(ClassMethods)
25
+ end
26
+
27
+ # the wire hash — optional fields left nil stay off the wire
28
+ def serialize
29
+ self.class.const_get(:FIELDS).each_with_object({}) do |field, wire|
30
+ value = public_send(field.prop)
31
+ next if value.nil? && !field.required
32
+
33
+ wire[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
34
+ end
35
+ end
36
+ alias_method :to_h, :serialize
37
+
38
+ module ClassMethods
39
+ include Kernel
40
+
41
+ # Build from a plain hash (underscored keys, Symbol or String):
42
+ # enums accept their wire values, nested inputs accept hashes; the
43
+ # struct's types are enforced on construction, and unknown keys
44
+ # raise with a spellchecked hint.
45
+ def coerce(value)
46
+ return value if value.is_a?(self)
47
+
48
+ # a typo'd key must not silently drop off the wire
49
+ GraphWeaver::Hints.validate_keys!(self, value)
50
+
51
+ fields = T.unsafe(self).const_get(:FIELDS)
52
+ T.unsafe(self).new(**fields.to_h do |field|
53
+ raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
54
+ [field.prop, raw.nil? || field.coercer.nil? ? raw : field.coercer.call(raw)]
55
+ end)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -24,6 +24,7 @@ class GraphWeaver::Railtie < Rails::Railtie
24
24
  end
25
25
 
26
26
  initializer "graph_weaver.load_generated", after: :load_config_initializers do
27
- GraphWeaver.load_generated! if Dir.exist?(GraphWeaver.generated_path)
27
+ # entries may be globs, so Dir[] rather than Dir.exist?
28
+ GraphWeaver.load_generated! if GraphWeaver.generated_paths.any? { |path| Dir[path].any? }
28
29
  end
29
30
  end
@@ -8,14 +8,12 @@ require_relative "testing"
8
8
  #
9
9
  # require "graph_weaver/rspec"
10
10
  #
11
- # That one line is the whole setup in a conventional app: the schema
12
- # auto-locates from the committed dump (GraphWeaver.schema_path) and
13
- # auto_fake defaults on, so every example runs against a schema-correct
14
- # fake. Configure to override:
11
+ # Then opt in to per-example fakes (explicit on purpose silently
12
+ # swapping every example onto a fake is too surprising to be a default):
15
13
  #
16
14
  # GraphWeaver::Testing.configure do |config|
17
- # config.schema = MySchema # instead of the located dump
18
- # config.auto_fake = false # opt out of per-example fakes
15
+ # config.auto_fake = true # every example runs against a fake
16
+ # # config.schema = MySchema # optional: the committed dump auto-locates
19
17
  # end
20
18
  #
21
19
  # What it wires up:
@@ -49,10 +49,10 @@ module GraphWeaver
49
49
  @mode = nil # auto
50
50
  @schema = nil
51
51
  @cassette_dir = "spec/cassettes"
52
- # on by default engages only when a schema resolves (below), so
53
- # requiring graph_weaver/rspec in a conventional app is
54
- # zero-config; config.auto_fake = false opts out
55
- @auto_fake = true
52
+ # explicit opt-in: swapping every example onto a fake is too
53
+ # surprising to be a default a little friction beats unexpected
54
+ # behavior (the schema still auto-locates once you opt in)
55
+ @auto_fake = false
56
56
  # GRAPHWEAVER_RECORD=1 rspec ... -> Cassette.use re-records
57
57
  @record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
58
58
  # anonymize responses as they're recorded (needs config.schema)
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end