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 +4 -4
- data/CHANGELOG.md +43 -0
- data/Gemfile.lock +2 -2
- data/PLAN.md +7 -0
- data/docs/generated_modules.md +43 -4
- data/docs/getting_started.md +8 -6
- data/docs/testing.md +21 -14
- data/lib/graph_weaver/client.rb +3 -2
- data/lib/graph_weaver/codegen/emit.rb +246 -56
- data/lib/graph_weaver/codegen/nodes.rb +35 -80
- data/lib/graph_weaver/codegen.rb +41 -104
- data/lib/graph_weaver/input_struct.rb +59 -0
- data/lib/graph_weaver/railtie.rb +2 -1
- data/lib/graph_weaver/rspec.rb +4 -6
- data/lib/graph_weaver/testing.rb +4 -4
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +103 -22
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdb688c1c86ee0e39da82446622479aeb99b887866326e892133efbabe8c5a3d
|
|
4
|
+
data.tar.gz: 9c5514556bbfb08b337d09be7df71d081eafe7b6ee678f658f5bf6086367fada
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
data/docs/generated_modules.md
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
data/docs/getting_started.md
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
against a seeded, schema-correct `FakeClient` — no server, no
|
|
99
|
-
and `rspec --seed 1234` reproduces the fake data along with test
|
|
100
|
-
Pin values with `overrides:`, simulate failures with `Failure
|
|
101
|
-
|
|
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,
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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.
|
|
47
|
-
config.
|
|
48
|
-
config.mode = :faker
|
|
49
|
-
config.overrides = { "Person.name" => "Daniel" }
|
|
50
|
-
config.list_size = 1..3
|
|
51
|
-
config.null_chance = 0.1
|
|
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
|
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -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 =
|
|
131
|
-
|
|
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
|
-
#
|
|
216
|
-
#
|
|
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}
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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}
|
|
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
|