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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef7de8e2cd115b4c757a3dac2e8c41a0e87865914e246131c1037a231f7a60af
4
- data.tar.gz: fa89ee78a04a2a07b7c173c82685490ca501a2a29045ef487aae3dd6f3d915d8
3
+ metadata.gz: bdb688c1c86ee0e39da82446622479aeb99b887866326e892133efbabe8c5a3d
4
+ data.tar.gz: 9c5514556bbfb08b337d09be7df71d081eafe7b6ee678f658f5bf6086367fada
5
5
  SHA512:
6
- metadata.gz: 4ae73ed74389baa1f08a42e38b7bf2b0bca2f95ccbe247ccb68fda733c9fee849aed3d17dbba424bdb13104344771d9789401710a5add195c71873edc379530b
7
- data.tar.gz: 24e15e7a6213c0cd5dee16aa73e675649748b84993be11387f638aeca21c15db77a59b1fa5a85ae406af7182be0f9f6a17164b842add3627a7547ab1670616d1
6
+ metadata.gz: 752fb13032272b5a9da29f549474444865a34552c336a32bb10316ec2c3bd38dd2996cc57604b30ca505ea35045f9abbd6d0bdefd0507d19714f3ab75f7dbc4d
7
+ data.tar.gz: ebce80c4ad9c3c471e086616f48afb83ea6e6741e7f1c44986bb5b7ba9f57112d144443e15a5b2b78e26297c20d2e2f8a9a4777559242a77f21ca2d4d25d1031
data/CHANGELOG.md CHANGED
@@ -1,3 +1,46 @@
1
+ ### v0.2.1 (2026-07-13)
2
+ - Conventional paths are appendable lists: queries_paths /
3
+ generated_paths (singular accessors read the first entry, so existing
4
+ config keeps working); load_generated!, Client#load_queries!, and the
5
+ Railtie walk every entry — append spec/support/graphql/* from a spec
6
+ helper to load test-only queries. Entries may be globs, and the
7
+ generated default includes app/graphql/*/generated so per-schema
8
+ layouts auto-load
9
+ - inputs_module derives from the output path: multi-schema layouts name
10
+ each schema's module after its directory
11
+ (app/graphql/github/generated -> GithubInputs), the conventional
12
+ layout keeps GraphQLInputs; GraphWeaver.inputs_module= and
13
+ generate!(inputs_module:) still override
14
+ - Shared types split one-file-per-type: generated/inputs/ holds each
15
+ input struct/enum in its own small file (PokeAPI: 573 files, median
16
+ 24 lines vs one 11.5k-line blob) with inputs.rb as the manifest
17
+ (forward declarations make load order irrelevant); regeneration
18
+ prunes files for types the schema dropped, verify flags strays;
19
+ generate!/verify take inputs_module: per invocation (multi-schema
20
+ apps generate into different modules)
21
+ - Shared input types: generate! emits every variable type (input
22
+ structs + their enums + mapped-enum tables) ONCE per schema into
23
+ generated/inputs.rb (module GraphQLInputs; GraphWeaver.inputs_module=
24
+ renames, shared_inputs: false opts out), with query modules aliasing
25
+ only what their own surface references — AdoptQuery::AdoptionInput
26
+ keeps working and shared types gain one identity across modules.
27
+ Three filtered Hasura queries: 34,684 lines inline -> 11,754 shared
28
+ (~90 lines per query module)
29
+ - BREAKING (vs 0.2.0): auto_fake is opt-in again — require
30
+ "graph_weaver/rspec" no longer swaps every example onto a fake;
31
+ set config.auto_fake = true explicitly (the schema still auto-locates
32
+ once you do). Less magic, no unexpected behavior
33
+ - Generated input structs are table-driven: typed consts + a per-field
34
+ FIELDS table (conversions as lambdas) interpreted by the
35
+ GraphWeaver::InputStruct runtime, replacing unrolled
36
+ serialize/coerce/value_at per struct — a bool_exp-heavy PokeAPI module
37
+ shrinks 29k -> 11.5k lines (-60%) with identical behavior (nil
38
+ omission, wire-value enums, nested/recursive coercion, spellchecked
39
+ unknown keys all covered by the existing suite)
40
+ - Internal: Node base class for the codegen IR protocol; module
41
+ assembly moved from Codegen#generate into Emit#emit_module
42
+ (byte-identical output)
43
+
1
44
  ### v0.2.0 (2026-07-12)
2
45
  - Cleanup pass (staff-engineer review): scalar registrations get the
3
46
  same typo validation as enums/types; cassette replay stops recomputing
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.2.0)
4
+ graph_weaver (0.2.1)
5
5
  graphql (>= 2)
6
6
  sorbet-runtime
7
7
 
@@ -183,7 +183,7 @@ CHECKSUMS
183
183
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
184
184
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
185
185
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
186
- graph_weaver (0.2.0)
186
+ graph_weaver (0.2.1)
187
187
  graphql (2.6.5) sha256=1051825bfb4aedb4293cdbcd9726c3150e69e3512556609a32c2ca19d4be3d00
188
188
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
189
189
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
data/PLAN.md CHANGED
@@ -105,6 +105,13 @@ Response envelope; execute! for raise-or-result).
105
105
 
106
106
  ## From the field-test experiments (2026-07-12)
107
107
 
108
+ Generated-module size (2026-07-12, post-0.2.0): input structs are now
109
+ table-driven (InputStruct runtime + FIELDS) — the PokeAPI filtered-query
110
+ module dropped 29,233 -> 11,562 lines. The floor is ~2 lines/field
111
+ (typed const + FIELDS entry). Remaining lever if it matters again:
112
+ shared input structs emitted once per schema instead of per module.
113
+
114
+
108
115
  A junior + senior agent pair exercised the repo cold (clone, examples,
109
116
  extensions, a Pokedex app against Hasura's 4,441-type PokeAPI schema).
110
117
  Fixed same-day: snake_case type names generated invalid constants
@@ -21,7 +21,10 @@ fails when the two drift. The conventional layout (configurable via
21
21
  app/graphql/
22
22
  schema.json # introspection dump (or schema.graphql SDL)
23
23
  queries/ # *.graphql — hand-written, reviewed
24
- generated/ # *_query.rb — generated, checked in, never edited
24
+ generated/
25
+ inputs.rb # manifest: requires + forward declarations
26
+ inputs/ # one file per shared type (input structs, enums)
27
+ *_query.rb # one module per query — generated, checked in, never edited
25
28
  ```
26
29
 
27
30
  The schema dump is step 0 — codegen reads it, never a live endpoint.
@@ -58,6 +61,20 @@ first). Elsewhere it's explicit, factory_bot-style:
58
61
  GraphWeaver.load_generated! # require every file under generated_path
59
62
  ```
60
63
 
64
+ The conventional paths are lists (entries may be globs — the default
65
+ includes `app/graphql/*/generated`, so per-schema layouts load too).
66
+ Append extra locations (a test-only schema, an engine's queries) and
67
+ every loader walks them all:
68
+
69
+ ```ruby
70
+ # e.g. in spec/support/graph_weaver.rb
71
+ GraphWeaver.generated_paths << "spec/support/graphql/generated"
72
+ GraphWeaver.queries_paths << "spec/support/graphql/queries"
73
+ ```
74
+
75
+ The singular accessors (`generated_path` etc.) read and replace the
76
+ first entry — the default target for `generate!` and the rake tasks.
77
+
61
78
  (Plain requires, not Zeitwerk: Zeitwerk would expect
62
79
  `Generated::PersonQuery` from `generated/person_query.rb`, and generated
63
80
  code only changes on regeneration — restart, like a schema migration.)
@@ -152,9 +169,31 @@ hint rather than silently dropping):
152
169
  AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
153
170
  ```
154
171
 
155
- The structs themselves are module-level (`AdoptQuery::AdoptionInput`) with
156
- `serialize` (aliased as `to_h`) producing the wire hash optional fields
157
- default nil and stay off the wire. Nested inputs work (dependencies emit
172
+ In the generate! workflow, input types (and the enums they use) are
173
+ emitted **once per schema** one file per type under
174
+ `generated/inputs/`, with `inputs.rb` as the manifest. The module is
175
+ named from the output path: the conventional layout gets
176
+ `GraphQLInputs`, while a multi-schema layout names each schema's module
177
+ after its directory (`app/graphql/github/generated` → `GithubInputs`).
178
+ Override globally with `GraphWeaver.inputs_module=` or per run with
179
+ `generate!(inputs_module:)`; opt out with
180
+ `generate!(shared_inputs: false)`. Per-type files keep schema drift
181
+ reviewable: a migration diffs exactly the types it touched, and types
182
+ the schema drops are pruned on regeneration (`verify` flags strays).
183
+ Query modules alias what they touch,
184
+ so `AdoptQuery::AdoptionInput` still works and shared types keep one
185
+ identity across modules — three filtered Hasura queries cost one ~11k-line
186
+ inputs file plus ~90 lines each, instead of ~35k lines of duplicates.
187
+ Deeply nested types live unaliased in the shared module
188
+ (`GraphQLInputs::PetFilter`). Dynamic `parse` stays self-contained.
189
+
190
+ The structs themselves are module-level (`AdoptQuery::AdoptionInput`):
191
+ typed consts plus a compact per-field `FIELDS` table that the
192
+ `GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`)
193
+ produces the wire hash with nil optionals omitted, `coerce` builds from
194
+ plain hashes. The conversions ship in the generated file as data
195
+ (lambdas in the table), so a Hasura `bool_exp` pulling hundreds of input
196
+ types stays ~2 lines per field instead of unrolled methods. Nested inputs work (dependencies emit
158
197
  first), including recursive ones — Hasura's self-referential `bool_exp`
159
198
  filters generate cleanly (`_and:`/`_not:` fields typed as the struct
160
199
  itself), so variable-driven filtering works:
@@ -91,14 +91,16 @@ PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
91
91
  ```ruby
92
92
  # spec/support/graph_weaver.rb
93
93
  require "graph_weaver/rspec"
94
+
95
+ GraphWeaver::Testing.configure { |config| config.auto_fake = true }
94
96
  ```
95
97
 
96
- That's the whole setup: the schema auto-locates from the committed dump
97
- and `auto_fake` defaults on, so every query in every example executes
98
- against a seeded, schema-correct `FakeClient` — no server, no stubs,
99
- and `rspec --seed 1234` reproduces the fake data along with test order.
100
- Pin values with `overrides:`, simulate failures with `Failure.*`, opt
101
- out with `config.auto_fake = false` — see [testing](testing.md).
98
+ The opt-in is deliberate (no surprise fakes); once on, the schema
99
+ auto-locates from the committed dump and every query in every example
100
+ executes against a seeded, schema-correct `FakeClient` — no server, no
101
+ stubs, and `rspec --seed 1234` reproduces the fake data along with test
102
+ order. Pin values with `overrides:`, simulate failures with `Failure.*`
103
+ — see [testing](testing.md).
102
104
 
103
105
  ## 7. Verify in CI
104
106
 
data/docs/testing.md CHANGED
@@ -31,24 +31,22 @@ GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
31
31
  })
32
32
  ```
33
33
 
34
- With rspec, one line in `spec/support/graph_weaver.rb` is the whole
35
- default setup the schema auto-locates from the committed dump at
36
- `GraphWeaver.schema_path`, and `auto_fake` defaults on:
34
+ With rspec, the setup is two lines in `spec/support/graph_weaver.rb`
35
+ the require, plus an explicit opt-in to per-example fakes (deliberately
36
+ not a default: silently swapping every example onto a fake would be
37
+ surprising). The schema auto-locates from the committed dump at
38
+ `GraphWeaver.schema_path`:
37
39
 
38
40
  ```ruby
39
- require "graph_weaver/rspec" # seed follows --seed; every example auto-fakes
40
- ```
41
-
42
- Configure to override any of it:
41
+ require "graph_weaver/rspec" # seed follows --seed
43
42
 
44
- ```ruby
45
43
  GraphWeaver::Testing.configure do |config|
46
- config.schema = MySchema # an in-process class instead of the located dump
47
- config.auto_fake = false # opt out of per-example fakes
48
- config.mode = :faker # or :literal (plain typed values); nil = auto
49
- config.overrides = { "Person.name" => "Daniel" }
50
- config.list_size = 1..3
51
- config.null_chance = 0.1 # nullable fields go nil sometimes
44
+ config.auto_fake = true # every example runs against a fresh fake
45
+ # config.schema = MySchema # optional: an in-process class instead of the dump
46
+ # config.mode = :faker # or :literal (plain typed values); nil = auto
47
+ # config.overrides = { "Person.name" => "Daniel" }
48
+ # config.list_size = 1..3
49
+ # config.null_chance = 0.1 # nullable fields go nil sometimes
52
50
  end
53
51
  ```
54
52
 
@@ -59,6 +57,15 @@ they consult `GraphWeaver.client`). `mode:` picks value fabrication: `:faker`
59
57
  (semantic, field-name matched — raises if the gem is missing),
60
58
  `:literal` (plain type-derived), or nil to auto-detect faker.
61
59
 
60
+ Test-only queries don't have to live in `app/` — the conventional paths
61
+ are appendable lists, so the same support file can register a
62
+ spec-local set that `load_generated!` (and the Railtie) pick up:
63
+
64
+ ```ruby
65
+ GraphWeaver.generated_paths << "spec/support/graphql/generated"
66
+ GraphWeaver.queries_paths << "spec/support/graphql/queries"
67
+ ```
68
+
62
69
  **Simulating failures** — every failure mode is just a client, so
63
70
  error-handling paths are testable without a server that misbehaves on cue:
64
71
 
@@ -127,8 +127,9 @@ class GraphWeaver::Client
127
127
  #
128
128
  # Reloadable (constants are replaced), so it suits consoles and dev.
129
129
  # Returns the modules.
130
- def load_queries!(dir = GraphWeaver.queries_path, namespace: Object)
131
- Dir[File.join(dir, "*.graphql")].sort.map do |path|
130
+ def load_queries!(dir = nil, namespace: Object)
131
+ dirs = dir ? [dir] : GraphWeaver.queries_paths
132
+ dirs.flat_map { |d| Dir[File.join(d, "*.graphql")].sort }.map do |path|
132
133
  name = "#{GraphWeaver::Inflect.camelize(File.basename(path, ".graphql"))}Query"
133
134
  namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
134
135
  GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
@@ -5,10 +5,244 @@
5
5
  # Mixed into Codegen — methods run with the generator instance state.
6
6
  class GraphWeaver::Codegen
7
7
  module Emit
8
+ include Kernel # for sorbet: hosts are Objects
8
9
  include GraphWeaver::Inflect
9
10
 
10
11
  private
11
12
 
13
+ # The Relay convention — an operation whose only variable is a required
14
+ # input object — reads better flattened: the input's fields become
15
+ # execute's kwargs directly, and the wrapping level is rebuilt on the
16
+ # wire. Multi-variable (or nullable-input) operations keep the
17
+ # variable-per-kwarg surface.
18
+ def flatten_input(variables)
19
+ return unless variables.size == 1
20
+
21
+ var = variables.first
22
+ return unless var.required && var.node.is_a?(NonNull)
23
+
24
+ input = var.node.of
25
+ input if input.is_a?(InputNode)
26
+ end
27
+
28
+ def input_references(node)
29
+ node.fields.filter_map do |field|
30
+ child = field.node
31
+ child = child.of while child.respond_to?(:of)
32
+ child if child.is_a?(InputNode)
33
+ end
34
+ end
35
+
36
+ # Input structs in dependency order (referenced types before their
37
+ # referrers) so each emitted const names an already-defined class.
38
+ # Cycles make that impossible — flagged so emission can forward-declare
39
+ # every input class first, then reopen each to add its props.
40
+ def ordered_inputs
41
+ ordered = []
42
+ seen = {} # node => :done | :visiting (bool_exp graphs get big)
43
+ cyclic = T.let(false, T::Boolean)
44
+
45
+ visit = lambda do |node|
46
+ next if seen[node] == :done
47
+
48
+ if seen[node] == :visiting
49
+ cyclic = true
50
+ next
51
+ end
52
+
53
+ seen[node] = :visiting
54
+ input_references(node).each(&visit)
55
+ seen[node] = :done
56
+ ordered << node
57
+ end
58
+ @variable_inputs.each_value(&visit)
59
+
60
+ [ordered, cyclic]
61
+ end
62
+
63
+ # Mapped-enum tables, generated enums, and input structs
64
+ # (dependency-ordered, forward-declared when cyclic) — inline in the
65
+ # module that needs them, or once in the shared inputs module.
66
+ def emit_variable_types(out)
67
+ @mapped_enums.each_value do |mapped|
68
+ emit_mapped_enum(mapped, out, 1)
69
+ out << ""
70
+ end
71
+ @variable_enums.each_value do |enum|
72
+ emit_enum(enum, out, 1)
73
+ out << ""
74
+ end
75
+ inputs, cyclic = ordered_inputs
76
+ if cyclic
77
+ # Recursive input types (Hasura bool_exp et al) reference each other,
78
+ # so no definition order satisfies the runtime — forward-declare every
79
+ # class empty, then let the full definitions below reopen with props.
80
+ # eval'd so srb sees only the full bodies (reopening a T::Struct to
81
+ # add props is a static error; adding them at runtime is fine).
82
+ out << " # runtime-only forward declarations: these input types reference"
83
+ out << " # each other, so the full definitions below need the constants"
84
+ out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
85
+ inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
86
+ out << " RUBY"
87
+ out << ""
88
+ end
89
+ inputs.each do |input|
90
+ emit_input(input, out, 1)
91
+ out << ""
92
+ end
93
+ end
94
+
95
+ # In the shared-inputs workflow the variable types live once in the
96
+ # inputs module; the query module aliases what it uses, so
97
+ # AdoptQuery::AdoptionInput stays a real constant — and shared types
98
+ # keep ONE identity across every module that touches them.
99
+ # Only the shared names THIS module's emission references: variable
100
+ # root types (and, when flattened, the root input's field types)
101
+ # plus every mapped-enum table (result casting reads them too).
102
+ # Nested types stay un-aliased — they live in the inputs module.
103
+ def shared_alias_names(variables, flatten)
104
+ nodes = variables.map(&:node)
105
+ nodes += flatten.fields.map(&:node) if flatten
106
+
107
+ names = @mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] }
108
+ nodes.each do |wrapped|
109
+ node = T.let(wrapped, T.untyped)
110
+ node = node.of while node.is_a?(NonNull) || node.is_a?(List)
111
+ case node
112
+ when EnumNode, InputNode then names << node.class_name
113
+ end
114
+ end
115
+ names.uniq
116
+ end
117
+
118
+ def emit_shared_aliases(out, names)
119
+ return if names.empty?
120
+
121
+ names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
122
+ out << ""
123
+ end
124
+
125
+ # The shared inputs artifact as files: inputs.rb (the manifest —
126
+ # requires, forward declarations for every struct so definition
127
+ # order never matters, then one require per type file) plus
128
+ # inputs/<type>.rb per enum/mapped-table/input struct.
129
+ def emit_inputs_files
130
+ files = {}
131
+ enum_files = []
132
+ struct_files = []
133
+
134
+ @mapped_enums.each do |graphql_name, mapped|
135
+ enum_files << inputs_file(files, graphql_name) { |out| emit_mapped_enum(mapped, out, 1) }
136
+ end
137
+ @variable_enums.each do |graphql_name, enum|
138
+ enum_files << inputs_file(files, graphql_name) { |out| emit_enum(enum, out, 1) }
139
+ end
140
+ inputs, = ordered_inputs
141
+ inputs.each do |input|
142
+ struct_files << inputs_file(files, input.class_name) { |out| emit_input(input, out, 1) }
143
+ end
144
+
145
+ manifest = []
146
+ manifest << "# typed: strict"
147
+ manifest << "# frozen_string_literal: true"
148
+ manifest << ""
149
+ manifest << "# Generated by GraphWeaver — do not edit. Shared variable types for"
150
+ manifest << "# this schema, one file per type; query modules alias what they use."
151
+ manifest << ""
152
+ requires = @requires.uniq.sort
153
+ if requires.any?
154
+ requires.each { |req| manifest << "require #{req.inspect}" }
155
+ manifest << ""
156
+ end
157
+ manifest << "module #{@module_name}; end"
158
+ manifest << ""
159
+ enum_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
160
+ if inputs.any?
161
+ manifest << ""
162
+ manifest << "# runtime-only forward declarations: input types reference each"
163
+ manifest << "# other across files, so every constant must exist before any"
164
+ manifest << "# definition loads (srb sees only the full bodies)"
165
+ manifest << "module #{@module_name}"
166
+ manifest << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
167
+ inputs.each { |input| manifest << " class #{input.class_name} < T::Struct; end" }
168
+ manifest << " RUBY"
169
+ manifest << "end"
170
+ manifest << ""
171
+ struct_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
172
+ end
173
+
174
+ files["inputs.rb"] = manifest.join("\n") + "\n"
175
+ files
176
+ end
177
+
178
+ # one type per file, wrapped in the namespace so bare sibling
179
+ # references resolve lexically
180
+ def inputs_file(files, name)
181
+ out = []
182
+ out << "# typed: strict"
183
+ out << "# frozen_string_literal: true"
184
+ out << ""
185
+ out << "# Generated by GraphWeaver — do not edit."
186
+ out << ""
187
+ out << "module #{@module_name}"
188
+ yield(out)
189
+ out << "end"
190
+
191
+ file = "inputs/#{GraphWeaver::Inflect.underscore(name)}.rb"
192
+ files[file] = out.join("\n") + "\n"
193
+ file
194
+ end
195
+
196
+ # The whole generated file: header, requires, the QUERY heredoc,
197
+ # enum tables, input structs (dependency-ordered, forward-declared
198
+ # when cyclic), the Result tree, and execute — assembled from the
199
+ # generator's walked state.
200
+ def emit_module(root, variables)
201
+ flatten = flatten_input(variables)
202
+ aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
203
+
204
+ out = []
205
+ out << "# typed: strict"
206
+ out << "# frozen_string_literal: true"
207
+ out << ""
208
+ out << "# Generated by GraphWeaver — do not edit."
209
+ out << ""
210
+ requires = @requires.uniq.sort
211
+ if requires.any?
212
+ requires.each { |req| out << "require #{req.inspect}" }
213
+ out << ""
214
+ end
215
+ if aliases.any?
216
+ # the aliases below need the shared module loaded (same directory
217
+ # by the generate! convention)
218
+ out << "require_relative \"inputs\""
219
+ out << ""
220
+ end
221
+ out << "module #{@module_name}"
222
+ out << " extend T::Sig"
223
+ out << ""
224
+ # a GraphQL block string could contain a bare GRAPHQL line, which
225
+ # would terminate the heredoc early — pick a delimiter the query
226
+ # can't collide with
227
+ delimiter = "GRAPHQL"
228
+ delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
229
+ out << " QUERY = T.let(<<~'#{delimiter}', String)"
230
+ @query.each_line { |line| out << " #{line}".rstrip }
231
+ out << " #{delimiter}"
232
+ out << ""
233
+ if @inputs_namespace
234
+ emit_shared_aliases(out, aliases)
235
+ else
236
+ emit_variable_types(out)
237
+ end
238
+ emit_nested(root, out, 1)
239
+ out << ""
240
+ emit_execute(out, variables, flatten:)
241
+ out << "end"
242
+
243
+ out.join("\n") + "\n"
244
+ end
245
+
12
246
  def emit_nested(node, out, indent)
13
247
  case node
14
248
  when UnionNode then emit_union(node, out, indent)
@@ -212,74 +446,30 @@ class GraphWeaver::Codegen
212
446
  end
213
447
  end
214
448
 
215
- # a module-level T::Struct per input type; serialize builds the wire
216
- # hash, omitting optional fields left nil
449
+ # A module-level T::Struct per input type: typed consts plus a FIELDS
450
+ # table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
451
+ # coerce live once in the gem, not unrolled per struct (bool_exp
452
+ # schemas pull hundreds of inputs into one module).
217
453
  def emit_input(node, out, indent)
218
454
  pad = " " * indent
219
455
 
220
456
  out << "#{pad}class #{node.class_name} < T::Struct"
221
- out << "#{pad} extend T::Sig"
457
+ out << "#{pad} include GraphWeaver::InputStruct"
458
+ out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
222
459
  out << ""
223
460
  node.fields.each do |field|
224
461
  default = field.required ? "" : ", default: nil"
225
462
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
226
463
  end
227
464
  out << ""
228
- # locals wear the reserved __gw prefix: prop readers are bare method
229
- # calls here, and a prop named "result" or "value" would otherwise
230
- # be shadowed — GraphQL reserves __-names, so no field can collide
231
- out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
232
- out << "#{pad} def serialize"
233
- out << "#{pad} __gw_result = T.let({}, T::Hash[String, T.untyped])"
465
+ out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
466
+ out << "#{pad} FIELDS = T.let(["
234
467
  node.fields.each do |field|
235
- if field.required || field.node.serialize_identity?
236
- value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
237
- line = "__gw_result[#{field.wire.inspect}] = #{value}"
238
- line += " unless #{field.prop}.nil?" unless field.required
239
- out << "#{pad} #{line}"
240
- else
241
- # bind a local so sorbet's flow-sensitivity narrows the nilable
242
- out << "#{pad} unless (__gw_value = #{field.prop}).nil?"
243
- out << "#{pad} __gw_result[#{field.wire.inspect}] = #{field.node.serialize("__gw_value", 1)}"
244
- out << "#{pad} end"
245
- end
468
+ serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
469
+ coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
470
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}),"
246
471
  end
247
- out << "#{pad} __gw_result"
248
- out << "#{pad} end"
249
- out << ""
250
- out << "#{pad} # serialize, under the conventional name"
251
- out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
252
- out << "#{pad} def to_h = serialize"
253
- out << ""
254
- out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
255
- out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
256
- out << "#{pad} # the struct's types are enforced on construction."
257
- out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
258
- out << "#{pad} def self.coerce(value)"
259
- out << "#{pad} return value if value.is_a?(#{node.class_name})"
260
- out << ""
261
- out << "#{pad} # a typo'd key must not silently drop off the wire"
262
- out << "#{pad} GraphWeaver::Hints.validate_keys!(self, value)"
263
- out << ""
264
- out << "#{pad} new("
265
- node.fields.each do |field|
266
- raw = "value_at(value, :#{field.prop})"
267
- expr = if field.node.hash_coerce_identity?
268
- raw
269
- elsif field.required
270
- "#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
271
- else
272
- "#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
273
- end
274
- out << "#{pad} #{field.prop}: #{expr},"
275
- end
276
- out << "#{pad} )"
277
- out << "#{pad} end"
278
- out << ""
279
- out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
280
- out << "#{pad} private_class_method def self.value_at(hash, key)"
281
- out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
282
- out << "#{pad} end"
472
+ out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
283
473
  out << "#{pad}end"
284
474
  end
285
475
  end