graph_weaver 0.2.0 → 0.2.2

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.
@@ -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.2"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -4,6 +4,7 @@ require "sorbet-runtime"
4
4
  require_relative "graph_weaver/logging"
5
5
  require_relative "graph_weaver/errors"
6
6
  require_relative "graph_weaver/hints"
7
+ require_relative "graph_weaver/input_struct"
7
8
  require_relative "graph_weaver/response"
8
9
  require_relative "graph_weaver/inflect"
9
10
  require_relative "graph_weaver/codegen"
@@ -56,15 +57,58 @@ module GraphWeaver
56
57
  target.is_a?(Client) ? target.transport! : target
57
58
  end
58
59
 
59
- # Conventional locations, factory_bot-style: used as defaults by
60
- # generate!, verify_generated!, load_generated!, and the rake tasks;
61
- # override the accessors or pass paths.
62
- attr_writer :queries_path, :generated_path, :schema_path
60
+ # Conventional locations, factory_bot-style LISTS, so extra
61
+ # locations (a test-only dir, an engine's) can be appended and every
62
+ # loader walks them all:
63
+ #
64
+ # # e.g. in spec/support/graph_weaver.rb
65
+ # GraphWeaver.generated_paths << "spec/support/graphql/generated"
66
+ # GraphWeaver.queries_paths << "spec/support/graphql/queries"
67
+ #
68
+ # The singular accessors read the first entry (the default target
69
+ # for generate! and the rake tasks); assigning one replaces the list.
70
+ attr_writer :queries_paths, :generated_paths, :schema_path
71
+
72
+ # Entries may be glob patterns — the generated default also matches
73
+ # per-schema layouts (app/graphql/github/generated). Queries stay
74
+ # single-schema: load_queries! parses everything against one client.
75
+ def queries_paths = @queries_paths ||= ["app/graphql/queries"]
76
+ def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
77
+
78
+ def queries_path = queries_paths.first
79
+ def generated_path = generated_paths.first
80
+
81
+ def queries_path=(path)
82
+ @queries_paths = path.nil? ? nil : [path]
83
+ end
84
+
85
+ def generated_path=(path)
86
+ @generated_paths = path.nil? ? nil : [path]
87
+ end
63
88
 
64
- def queries_path = @queries_path || "app/graphql/queries"
65
- def generated_path = @generated_path || "app/graphql/generated"
66
89
  def schema_path = @schema_path || "app/graphql/schema.json"
67
90
 
91
+ # The shared-inputs module name: set it globally, pass inputs_module:
92
+ # per generate!, or let it derive from the output path — the
93
+ # directory above generated/ names the schema in multi-schema
94
+ # layouts (app/graphql/github/generated => GithubInputs); the
95
+ # conventional layout (and anything unrecognizable) stays
96
+ # GraphQLInputs.
97
+ attr_writer :inputs_module
98
+
99
+ def inputs_module(output = generated_path)
100
+ return @inputs_module if @inputs_module
101
+
102
+ segments = File.expand_path(output.to_s).split(File::SEPARATOR)
103
+ segments.pop if segments.last == "generated"
104
+ parent = segments.last.to_s
105
+ if parent.match?(/\A[a-zA-Z]\w*\z/) && !%w[graphql app lib spec support test].include?(parent)
106
+ "#{Inflect.camelize(parent)}Inputs"
107
+ else
108
+ "GraphQLInputs"
109
+ end
110
+ end
111
+
68
112
  # Generate every .graphql query in a directory into checked-in Ruby
69
113
  # files. Paths default to the conventions above; schema: defaults to
70
114
  # the dump at schema_path (any supported extension):
@@ -73,16 +117,28 @@ module GraphWeaver
73
117
  #
74
118
  # person.graphql => person_query.rb defining PersonQuery. Returns the
75
119
  # written paths. Pair with a freshness spec (docs/generated_modules.md).
76
- def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil)
120
+ def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil,
121
+ shared_inputs: true, inputs_module: nil)
77
122
  schema ||= locate_schema!
78
- FileUtils.mkdir_p(output)
123
+ inputs_module ||= self.inputs_module(output)
79
124
 
80
- each_query(queries, schema:, client:).map do |base, source|
81
- target = File.join(output, "#{base}_query.rb")
125
+ plan = generation_plan(queries:, schema:, client:, shared_inputs:, inputs_module:)
126
+ written = plan.map do |filename, source|
127
+ target = File.join(output, filename)
128
+ FileUtils.mkdir_p(File.dirname(target))
82
129
  File.write(target, source)
83
130
  log(:info) { "generated #{target}" }
84
131
  target
85
132
  end
133
+
134
+ # a type dropped from the schema must not linger as a stale file —
135
+ # inputs/ is wholly generated, so pruning is safe
136
+ (Dir[File.join(output, "inputs", "*.rb")] - written).each do |orphan|
137
+ File.delete(orphan)
138
+ log(:info) { "pruned #{orphan}" }
139
+ end
140
+
141
+ written
86
142
  end
87
143
 
88
144
  # The freshness guard: raise unless every generated file matches what
@@ -92,12 +148,17 @@ module GraphWeaver
92
148
  # it "generated queries are current" do
93
149
  # GraphWeaver.verify_generated!
94
150
  # end
95
- def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil)
151
+ def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil,
152
+ shared_inputs: true, inputs_module: nil)
96
153
  schema ||= locate_schema!
97
- stale = each_query(queries, schema:, client:).filter_map do |base, source|
98
- target = File.join(output, "#{base}_query.rb")
154
+ inputs_module ||= self.inputs_module(output)
155
+ plan = generation_plan(queries:, schema:, client:, shared_inputs:, inputs_module:)
156
+ stale = plan.filter_map do |filename, source|
157
+ target = File.join(output, filename)
99
158
  target unless File.exist?(target) && File.read(target) == source
100
159
  end
160
+ # strays: a type file the current schema no longer produces
161
+ stale += Dir[File.join(output, "inputs", "*.rb")] - plan.map { |f, _| File.join(output, f) }
101
162
 
102
163
  unless stale.empty?
103
164
  raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
@@ -116,10 +177,11 @@ module GraphWeaver
116
177
  # Generated::PersonQuery from generated/person_query.rb, and
117
178
  # generated code only changes on regeneration anyway (restart, like
118
179
  # a schema migration).
119
- def load_generated!(path = generated_path)
120
- files = Dir[File.join(path, "**/*.rb")].sort
180
+ def load_generated!(path = nil)
181
+ paths = path ? [path] : generated_paths
182
+ files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
121
183
  files.each { |file| require File.expand_path(file) }
122
- log(:info) { "loaded #{files.size} generated module(s) from #{path}" }
184
+ log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
123
185
  files
124
186
  end
125
187
 
@@ -130,20 +192,39 @@ module GraphWeaver
130
192
  end
131
193
  private :locate_schema!
132
194
 
133
- # (base, generated_source) per .graphql file in a directory
134
- def each_query(queries, schema:, client:)
135
- Dir[File.join(queries, "*.graphql")].sort.map do |path|
195
+ # (filename, source) per artifact: shared_inputs (the default) emits
196
+ # every variable type once into inputs.rb, with query modules
197
+ # aliasing what they use — the difference between hundreds of
198
+ # duplicated bool_exp structs and one copy per schema.
199
+ def generation_plan(queries:, schema:, client:, shared_inputs:, inputs_module: self.inputs_module)
200
+ namespace = shared_inputs ? inputs_module : nil
201
+ used = { inputs: [], enums: [], mapped: [] }
202
+
203
+ plan = Dir[File.join(queries, "*.graphql")].sort.map do |path|
136
204
  base = File.basename(path, ".graphql")
137
- source = Codegen.generate(
205
+ codegen = Codegen.new(
138
206
  schema:,
139
207
  query: File.read(path),
140
208
  module_name: "#{Inflect.camelize(base)}Query",
141
209
  client:,
210
+ inputs_namespace: namespace,
142
211
  )
143
- [base, source]
212
+ source = codegen.generate
213
+ codegen.variable_type_names.each { |kind, names| used[kind] |= names }
214
+ ["#{base}_query.rb", source]
144
215
  end
216
+
217
+ if namespace && used.values.any?(&:any?)
218
+ shared = Codegen.generate_inputs(
219
+ schema:, module_name: namespace,
220
+ input_types: used[:inputs], enum_types: used[:enums] + used[:mapped],
221
+ )
222
+ plan = shared.to_a + plan
223
+ end
224
+
225
+ plan
145
226
  end
146
- private :each_query
227
+ private :generation_plan
147
228
 
148
229
  # Default input coercion for scalars that don't say coerce: themselves,
149
230
  # resolved lazily at generation time (so set it any time before you
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -255,6 +255,7 @@ files:
255
255
  - lib/graph_weaver/errors.rb
256
256
  - lib/graph_weaver/hints.rb
257
257
  - lib/graph_weaver/inflect.rb
258
+ - lib/graph_weaver/input_struct.rb
258
259
  - lib/graph_weaver/logging.rb
259
260
  - lib/graph_weaver/railtie.rb
260
261
  - lib/graph_weaver/response.rb