graph_weaver 0.0.1 → 0.1.0
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 +66 -0
- data/Gemfile.lock +45 -17
- data/Makefile +6 -1
- data/PLAN.md +52 -22
- data/README.md +62 -17
- data/docs/errors.md +106 -0
- data/docs/generated_modules.md +119 -0
- data/docs/real_world.md +46 -0
- data/docs/scalars.md +69 -0
- data/docs/testing.md +93 -0
- data/graph_weaver.gemspec +3 -0
- data/lib/graph_weaver/codegen/emit.rb +237 -0
- data/lib/graph_weaver/codegen/nodes.rb +256 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +238 -0
- data/lib/graph_weaver/codegen.rb +156 -395
- data/lib/graph_weaver/errors.rb +310 -0
- data/lib/graph_weaver/faraday_executor.rb +61 -0
- data/lib/graph_weaver/http_executor.rb +16 -3
- data/lib/graph_weaver/inflect.rb +18 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/rspec.rb +54 -0
- data/lib/graph_weaver/schema_loader.rb +73 -9
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/testing/cassette.rb +203 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_executor.rb +228 -0
- data/lib/graph_weaver/testing/rspec.rb +5 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +90 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +97 -3
- metadata +63 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Generated modules
|
|
2
|
+
|
|
3
|
+
`GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
|
|
4
|
+
Ruby module. Everything `srb tc` knows about your query results comes from this
|
|
5
|
+
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
6
|
+
|
|
7
|
+
## Anatomy
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
module PersonQuery
|
|
11
|
+
QUERY = "..." # the operation, verbatim
|
|
12
|
+
|
|
13
|
+
class Result < T::Struct # the response shape, exactly as selected
|
|
14
|
+
class Person < T::Struct
|
|
15
|
+
const :name, String # non-null in the schema
|
|
16
|
+
const :birthday, T.nilable(Date)
|
|
17
|
+
|
|
18
|
+
def self.from_h(data) ... # generated casting — no reflection
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
const :person, T.nilable(Person)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.executor ... # default transport (see below)
|
|
25
|
+
def self.execute(id:, executor: self.executor) # -> GraphWeaver::Response[Result]
|
|
26
|
+
def self.execute!(id:, executor: self.executor) # -> Result, or raises QueryError
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
|
|
31
|
+
`#data`, `#data!`, `#errors`, `#extensions` — so partial data and
|
|
32
|
+
cost/throttle metadata survive.
|
|
33
|
+
- `execute!` is the shortcut: the typed result or a raised
|
|
34
|
+
`GraphWeaver::QueryError`.
|
|
35
|
+
|
|
36
|
+
## Variables become typed kwargs
|
|
37
|
+
|
|
38
|
+
```graphql
|
|
39
|
+
mutation($name: String!, $species: Species!, $note: String) { ... }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- required vs optional falls out of nullability and defaults: nullable or
|
|
47
|
+
defaulted variables become optional kwargs (nil is omitted from the wire,
|
|
48
|
+
so server-side defaults apply)
|
|
49
|
+
- enum variables generate module-level `T::Enum`s and serialize themselves
|
|
50
|
+
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
51
|
+
|
|
52
|
+
**Input objects** generate module-level `T::Struct`s with `serialize`
|
|
53
|
+
(aliased as `to_h`) producing the wire hash — optional fields default nil and
|
|
54
|
+
stay off the wire:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
input = AdoptQuery::AdoptionInput.new(name: "Rex", species: AdoptQuery::Species::Dog)
|
|
58
|
+
AdoptQuery.execute!(input:)
|
|
59
|
+
|
|
60
|
+
# or pass a plain hash straight to execute — .coerce normalizes and type-checks it at
|
|
61
|
+
# the boundary (underscored Symbol/String keys; enums accept wire values;
|
|
62
|
+
# nested inputs accept hashes)
|
|
63
|
+
AdoptQuery.execute!(input: { name: "Rex", species: "DOG" })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Nested inputs work (dependencies emit first); recursive input types are not
|
|
67
|
+
yet supported.
|
|
68
|
+
|
|
69
|
+
## Selections
|
|
70
|
+
|
|
71
|
+
- **Fragments** — inline fragments and named spreads flatten into the
|
|
72
|
+
selection; type conditions match exact names or interfaces/unions the type
|
|
73
|
+
belongs to.
|
|
74
|
+
- **Unions and interfaces** — each abstract selection site emits a module:
|
|
75
|
+
one member struct per possible type, `Type = T.type_alias { T.any(...) }`,
|
|
76
|
+
and a `from_h` dispatching on `__typename`. Generation *requires*
|
|
77
|
+
`__typename` in the selection so dispatch is possible.
|
|
78
|
+
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
79
|
+
the response regardless of schema nullability, so its generated type is
|
|
80
|
+
always nilable.
|
|
81
|
+
- **Aliases** — result keys follow aliases; props are the underscored alias.
|
|
82
|
+
|
|
83
|
+
## Naming
|
|
84
|
+
|
|
85
|
+
Module names derive from the operation name (`query GetPerson` → `GetPerson`);
|
|
86
|
+
`GraphWeaver.parse` on a `.graphql` file derives from the file name
|
|
87
|
+
(`person.graphql` → `PersonQuery`). Pass `module_name:`/`name:` to override.
|
|
88
|
+
`Codegen.generate` requires a deliberate name for anonymous operations; dynamic
|
|
89
|
+
`parse` defaults to `Query` (its constants are container-scoped, so collisions
|
|
90
|
+
are impossible).
|
|
91
|
+
|
|
92
|
+
Nested struct names come from GraphQL type names, disambiguated one level by
|
|
93
|
+
field name on collision.
|
|
94
|
+
|
|
95
|
+
## Executors
|
|
96
|
+
|
|
97
|
+
An executor is anything with `execute(query, variables:)` whose result `to_h`s
|
|
98
|
+
into `{"data" => ..., "errors" => ...}`. Resolution order:
|
|
99
|
+
|
|
100
|
+
1. per call: `execute(..., executor: something)`
|
|
101
|
+
2. per module: `PersonQuery.executor = something`
|
|
102
|
+
3. baked constant: `Codegen.generate(..., executor: MyApi::Executor)`
|
|
103
|
+
4. global: `GraphWeaver.executor` (raises helpfully when unconfigured)
|
|
104
|
+
|
|
105
|
+
Generate *without* a baked constant when you want modules to follow the
|
|
106
|
+
global — that's also what lets [testing's auto_fake](testing.md) swap in a
|
|
107
|
+
fake per example.
|
|
108
|
+
|
|
109
|
+
## Dynamic mode
|
|
110
|
+
|
|
111
|
+
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
|
|
112
|
+
into an anonymous container — no global constants leak). Same runtime
|
|
113
|
+
semantics; invisible to `srb tc`, so prefer the build step where static
|
|
114
|
+
checking matters. `GraphWeaver.execute(schema:, query:, variables: {})` is
|
|
115
|
+
the one-shot form.
|
|
116
|
+
|
|
117
|
+
Generated source is eval'd, so inputs are validated: module names must be
|
|
118
|
+
constant names, and query heredocs can't be terminated early. Still: queries
|
|
119
|
+
are code — don't feed untrusted strings to parse.
|
data/docs/real_world.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Against a real API
|
|
2
|
+
|
|
3
|
+
Everything composes for a remote endpoint — introspect the schema
|
|
4
|
+
straight off the wire, then query with typed results. GitHub's API,
|
|
5
|
+
end to end:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "graph_weaver"
|
|
9
|
+
|
|
10
|
+
executor = GraphWeaver::HttpExecutor.new(
|
|
11
|
+
"https://api.github.com/graphql",
|
|
12
|
+
headers: { "Authorization" => "Bearer #{`gh auth token`.strip}" },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# introspecting a big API takes seconds — cache: stores the introspection
|
|
16
|
+
# JSON in a file and reuses it for ttl: seconds. For Rails.cache/redis,
|
|
17
|
+
# cache SchemaLoader.introspection_result(executor) (a plain Hash) instead.
|
|
18
|
+
schema = GraphWeaver::SchemaLoader.introspect(
|
|
19
|
+
executor,
|
|
20
|
+
cache: "tmp/github-schema.json",
|
|
21
|
+
ttl: 24 * 60 * 60,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# map GitHub's DateTime scalar onto Time (cast inferred from Time.parse)
|
|
25
|
+
GraphWeaver.register_scalar("DateTime", type: Time, serialize: :iso8601, requires: "time")
|
|
26
|
+
|
|
27
|
+
RepoQuery = GraphWeaver.parse(schema:, executor:, query: <<~GRAPHQL)
|
|
28
|
+
query($owner: String!, $name: String!) {
|
|
29
|
+
repository(owner: $owner, name: $name) {
|
|
30
|
+
nameWithOwner
|
|
31
|
+
createdAt
|
|
32
|
+
stargazerCount
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
GRAPHQL
|
|
36
|
+
|
|
37
|
+
repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
|
|
38
|
+
repo&.name_with_owner # => "dpep/graph_weaver"
|
|
39
|
+
repo&.created_at # => 2026-07-07 ... (a real Time)
|
|
40
|
+
repo&.stargazer_count # => Integer
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The same flow runs as one-off integration specs against the live GitHub
|
|
44
|
+
and Countries APIs — `make integration` (network; GitHub auth via
|
|
45
|
+
`gh auth token` or `GITHUB_TOKEN`).
|
|
46
|
+
|
data/docs/scalars.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Custom scalars
|
|
2
|
+
|
|
3
|
+
Teach the generator how a GraphQL custom scalar deserializes into a rich
|
|
4
|
+
Ruby object (and serializes back when used as a variable). A field typed
|
|
5
|
+
`Money` then generates `const :price, T.nilable(Money)` and casts with
|
|
6
|
+
`Money.parse(...)` inline — no runtime reflection:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
GraphWeaver.register_scalar("Money", type: Money, requires: "bigdecimal")
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Pass a real class as `type:` and the cast/serialize are **inferred** from it by
|
|
13
|
+
probing the deserialize side and pairing its serializer:
|
|
14
|
+
|
|
15
|
+
| the class defines | cast | serialize |
|
|
16
|
+
|-------------------|---------------|----------------|
|
|
17
|
+
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
18
|
+
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
19
|
+
|
|
20
|
+
so the common case needs nothing more. Probing the *deserialize* side is
|
|
21
|
+
deliberate — every object has `#to_s`, so inferring off it would wrongly wrap
|
|
22
|
+
plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
|
|
23
|
+
actually defines avoids that (and is why the built-ins can be registered with
|
|
24
|
+
their real class constants). Override explicitly when you need to:
|
|
25
|
+
|
|
26
|
+
- a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
|
|
27
|
+
`serialize: :to_json` → `expr.to_json`
|
|
28
|
+
- a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
|
|
29
|
+
- `:itself` to force pass-through, opting out of inference (rare)
|
|
30
|
+
|
|
31
|
+
`type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
|
|
32
|
+
reference the class. `requires:` (a string or array) names files emitted as
|
|
33
|
+
`require`s atop the generated source so the cast/type resolve. When `type:` is
|
|
34
|
+
a real class (so the runtime is loaded), each path is also `require`d at
|
|
35
|
+
registration — a typo fails now, not in the generated file.
|
|
36
|
+
|
|
37
|
+
Pass `coerce: true` to let a variable of this scalar accept **either** the value
|
|
38
|
+
object **or** its raw input, normalizing the latter through the cast:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
GraphWeaver.register_scalar("Money", type: Money, coerce: true)
|
|
42
|
+
# generated execute now takes T.any(Money, String); "12.00" is parsed
|
|
43
|
+
StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
|
|
44
|
+
StoreQuery.execute(budget: Money.new(1200)) # passed straight through
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Bad input still explodes (the cast raises), so some safety survives; coercion
|
|
48
|
+
needs both a cast and a serialize. Off by default — the strict typed kwarg is the norm.
|
|
49
|
+
|
|
50
|
+
`coerce:` also takes a **Symbol** naming a conversion method, for built-ins where
|
|
51
|
+
a plain method is the whole story — `coerce: :to_f` makes a variable accept
|
|
52
|
+
`5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
|
|
53
|
+
convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
|
|
54
|
+
`ID`/`String`→`:to_s`), so rather than re-registering each, flip them all on at
|
|
55
|
+
once:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
GraphWeaver.reset_scalars!(coerce: true) # reload built-ins as coercible
|
|
59
|
+
GraphWeaver.register_scalar("Money", ...) # then add your own
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`Boolean` and `Date` have no lossless one-method conversion, so they stay strict.
|
|
63
|
+
|
|
64
|
+
The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
|
|
65
|
+
same path (`Date` even carries its own `require "date"`), so a later
|
|
66
|
+
`register_scalar` overrides them; `GraphWeaver.reset_scalars!` restores the
|
|
67
|
+
defaults (`reset_scalars!(coerce: true)` restores them coercible) and
|
|
68
|
+
`clear_scalars!` empties the registry. Register before generating — it's a
|
|
69
|
+
codegen-time concern, baked into the emitted source.
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`require "graph_weaver/rspec"` from your spec helper (or
|
|
4
|
+
`graph_weaver/testing` outside rspec — never production code) for a
|
|
5
|
+
zero-setup fake backend. `FakeExecutor` fabricates
|
|
6
|
+
schema-correct responses for whatever query arrives: real enum values,
|
|
7
|
+
valid `__typename` members, iso8601 date scalars — every fake casts
|
|
8
|
+
cleanly through your generated structs.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
fake = GraphWeaver::Testing::FakeExecutor.new(schema:)
|
|
12
|
+
|
|
13
|
+
person = PersonQuery.execute!(id: "1", executor: fake).person
|
|
14
|
+
person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
|
|
15
|
+
person.birthday # => a real Date
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Pin what matters, keyed by GraphQL names (schema vocabulary — keys
|
|
19
|
+
survive query refactors); `"Type.field"` beats `"field"`:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
GraphWeaver::Testing::FakeExecutor.new(schema:, overrides: {
|
|
23
|
+
"Person.name" => "Daniel",
|
|
24
|
+
"email" => -> { "test@example.com" },
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or configure once, initializer-style (e.g. in `spec/support/graph_weaver.rb`):
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "graph_weaver/rspec" # rspec: seed follows --seed
|
|
32
|
+
|
|
33
|
+
GraphWeaver::Testing.configure do |config|
|
|
34
|
+
config.schema = MySchema
|
|
35
|
+
config.auto_fake = true # every example runs against a fresh FakeExecutor
|
|
36
|
+
config.mode = :faker # or :literal (plain typed values); nil = auto
|
|
37
|
+
config.overrides = { "Person.name" => "Daniel" }
|
|
38
|
+
config.list_size = 1..3
|
|
39
|
+
config.null_chance = 0.1 # nullable fields go nil sometimes
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
With the rspec integration, `rspec --seed 1234` reproduces fake data
|
|
44
|
+
along with test order, and `auto_fake` installs a seeded executor per
|
|
45
|
+
example (generate modules *without* a baked `executor:` so they consult
|
|
46
|
+
`GraphWeaver.executor`). `mode:` picks value fabrication: `:faker`
|
|
47
|
+
(semantic, field-name matched — raises if the gem is missing),
|
|
48
|
+
`:literal` (plain type-derived), or nil to auto-detect faker.
|
|
49
|
+
|
|
50
|
+
**Simulating failures** — every failure mode is just an executor, so
|
|
51
|
+
error-handling paths are testable without a server that misbehaves on cue:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
Failure = GraphWeaver::Testing::Failure
|
|
55
|
+
|
|
56
|
+
PersonQuery.execute(id: "1", executor: Failure.transport) # TransportError (cause preserved)
|
|
57
|
+
PersonQuery.execute(id: "1", executor: Failure.server(status: 502)) # ServerError
|
|
58
|
+
PersonQuery.execute(id: "1", executor: Failure.throttled) # QueryError, code THROTTLED
|
|
59
|
+
PersonQuery.execute(id: "1", executor: Failure.stale_schema) # schema_stale? => true
|
|
60
|
+
PersonQuery.execute(id: "1", executor: Failure.graphql("boom", data: {...})) # partial failure
|
|
61
|
+
|
|
62
|
+
# retries: fail twice, then succeed
|
|
63
|
+
GraphWeaver::Testing::SequenceExecutor.new(Failure.transport, Failure.transport, fake)
|
|
64
|
+
|
|
65
|
+
# type mismatch: corrupt: derives a wrong-typed wire value for the field —
|
|
66
|
+
# casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
|
|
67
|
+
GraphWeaver::Testing::FakeExecutor.new(schema:, corrupt: "Person.birthday")
|
|
68
|
+
|
|
69
|
+
# stale schema naming a real (sampled) field
|
|
70
|
+
Failure.stale_schema(schema: MySchema)
|
|
71
|
+
|
|
72
|
+
# field-level partial failure with real GraphQL null propagation: the error
|
|
73
|
+
# lands with its concrete path and nulls bubble to the nearest nullable spot
|
|
74
|
+
GraphWeaver::Testing::FakeExecutor.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Capture and replay** — cassettes live above the transport (no HTTP
|
|
78
|
+
interception), so they work identically for HTTP, Faraday, or in-process
|
|
79
|
+
executors:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# records against the live executor when the file is missing, replays after
|
|
83
|
+
executor = GraphWeaver::Testing::Cassette.use("github", executor: live)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Cassettes hold real responses. Anonymize before committing — shape,
|
|
87
|
+
nulls, list lengths, enums, and id relationships survive; values are
|
|
88
|
+
replaced with (semantically matched) fakes:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
|
|
92
|
+
```
|
|
93
|
+
|
data/graph_weaver.gemspec
CHANGED
|
@@ -15,7 +15,10 @@ Gem::Specification.new do |s|
|
|
|
15
15
|
s.add_dependency "graphql", ">= 2"
|
|
16
16
|
s.add_dependency "sorbet-runtime"
|
|
17
17
|
|
|
18
|
+
s.add_development_dependency "bigdecimal"
|
|
18
19
|
s.add_development_dependency "debug"
|
|
20
|
+
s.add_development_dependency "faker"
|
|
21
|
+
s.add_development_dependency "faraday"
|
|
19
22
|
s.add_development_dependency "rspec"
|
|
20
23
|
s.add_development_dependency "simplecov"
|
|
21
24
|
s.add_development_dependency "sorbet"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Source emission: turns the node tree into the generated module text.
|
|
5
|
+
# Mixed into Codegen — methods run with the generator instance state.
|
|
6
|
+
class GraphWeaver::Codegen
|
|
7
|
+
module Emit
|
|
8
|
+
include GraphWeaver::Inflect
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def emit_nested(node, out, indent)
|
|
13
|
+
case node
|
|
14
|
+
when UnionNode then emit_union(node, out, indent)
|
|
15
|
+
when EnumNode then emit_enum(node, out, indent)
|
|
16
|
+
else emit_object(node, out, indent)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit_enum(node, out, indent)
|
|
21
|
+
pad = " " * indent
|
|
22
|
+
|
|
23
|
+
out << "#{pad}class #{node.class_name} < T::Enum"
|
|
24
|
+
out << "#{pad} enums do"
|
|
25
|
+
node.values.each do |value|
|
|
26
|
+
out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
|
|
27
|
+
end
|
|
28
|
+
out << "#{pad} end"
|
|
29
|
+
out << "#{pad}end"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emit_object(node, out, indent)
|
|
33
|
+
pad = " " * indent
|
|
34
|
+
|
|
35
|
+
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
36
|
+
out << "#{pad} extend T::Sig"
|
|
37
|
+
out << ""
|
|
38
|
+
|
|
39
|
+
node.fields.filter_map { |field| field.node.nested }.each do |child|
|
|
40
|
+
emit_nested(child, out, indent + 1)
|
|
41
|
+
out << ""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
node.fields.each do |field|
|
|
45
|
+
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
out << ""
|
|
49
|
+
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(#{node.class_name}) }"
|
|
50
|
+
out << "#{pad} def self.from_h(data)"
|
|
51
|
+
out << "#{pad} new("
|
|
52
|
+
node.fields.each do |field|
|
|
53
|
+
out << "#{pad} #{field.prop}: #{field_cast(field)},"
|
|
54
|
+
end
|
|
55
|
+
out << "#{pad} )"
|
|
56
|
+
out << "#{pad} rescue GraphWeaver::TypeError"
|
|
57
|
+
out << "#{pad} raise # already wrapped by a nested struct — keep the innermost context"
|
|
58
|
+
out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
|
|
59
|
+
out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
|
|
60
|
+
out << "#{pad} end"
|
|
61
|
+
out << "#{pad}end"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def emit_union(node, out, indent)
|
|
65
|
+
pad = " " * indent
|
|
66
|
+
|
|
67
|
+
out << "#{pad}module #{node.class_name}"
|
|
68
|
+
out << "#{pad} extend T::Sig"
|
|
69
|
+
out << ""
|
|
70
|
+
|
|
71
|
+
node.members.each_value do |member|
|
|
72
|
+
emit_object(member, out, indent + 1)
|
|
73
|
+
out << ""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
member_names = node.members.values.map(&:class_name)
|
|
77
|
+
type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
|
|
78
|
+
out << "#{pad} Type = T.type_alias { #{type_alias} }"
|
|
79
|
+
out << ""
|
|
80
|
+
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
81
|
+
out << "#{pad} def self.from_h(data)"
|
|
82
|
+
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
83
|
+
node.members.each do |graphql_name, member|
|
|
84
|
+
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
85
|
+
end
|
|
86
|
+
out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
|
|
87
|
+
out << "#{pad} end"
|
|
88
|
+
out << "#{pad} end"
|
|
89
|
+
out << "#{pad}end"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def emit_execute(out, variables)
|
|
93
|
+
out << " @executor = T.let(nil, T.untyped)"
|
|
94
|
+
out << ""
|
|
95
|
+
out << " class << self"
|
|
96
|
+
out << " extend T::Sig"
|
|
97
|
+
out << ""
|
|
98
|
+
out << " sig { params(executor: T.untyped).void }"
|
|
99
|
+
out << " attr_writer :executor"
|
|
100
|
+
out << ""
|
|
101
|
+
out << " # default transport for execute"
|
|
102
|
+
out << " sig { returns(T.untyped) }"
|
|
103
|
+
out << " def executor"
|
|
104
|
+
out << " @executor || #{@executor_const || "GraphWeaver.executor"}"
|
|
105
|
+
out << " end"
|
|
106
|
+
out << " end"
|
|
107
|
+
out << ""
|
|
108
|
+
|
|
109
|
+
sig_params = variables.map do |var|
|
|
110
|
+
bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
|
|
111
|
+
kwarg_type = var.required ? bare : "T.nilable(#{bare})"
|
|
112
|
+
"#{var.kwarg}: #{kwarg_type}"
|
|
113
|
+
end
|
|
114
|
+
sig_params << "executor: T.untyped"
|
|
115
|
+
|
|
116
|
+
kwargs = variables.map { |var| var.required ? "#{var.kwarg}:" : "#{var.kwarg}: nil" }
|
|
117
|
+
kwargs << "executor: self.executor"
|
|
118
|
+
|
|
119
|
+
# execute returns the full envelope; execute! is the strict shortcut for
|
|
120
|
+
# `execute(...).data!` — the typed result, or a raised QueryError.
|
|
121
|
+
forward = (variables.map { |var| "#{var.kwarg}: #{var.kwarg}" } + ["executor: executor"]).join(", ")
|
|
122
|
+
|
|
123
|
+
out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
|
|
124
|
+
out << " def self.execute(#{kwargs.join(", ")})"
|
|
125
|
+
|
|
126
|
+
required, optional = variables.partition(&:required)
|
|
127
|
+
if required.empty?
|
|
128
|
+
out << " variables = {}"
|
|
129
|
+
else
|
|
130
|
+
out << " variables = {"
|
|
131
|
+
required.each do |var|
|
|
132
|
+
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
133
|
+
end
|
|
134
|
+
out << " }"
|
|
135
|
+
end
|
|
136
|
+
optional.each do |var|
|
|
137
|
+
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
out << ""
|
|
141
|
+
out << " raw = executor.execute(QUERY, variables: variables).to_h"
|
|
142
|
+
out << " GraphWeaver::Response[Result].new("
|
|
143
|
+
out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
|
|
144
|
+
out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
|
|
145
|
+
out << " extensions: raw[\"extensions\"] || {},"
|
|
146
|
+
out << " )"
|
|
147
|
+
out << " end"
|
|
148
|
+
out << ""
|
|
149
|
+
out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
|
|
150
|
+
out << " def self.execute!(#{kwargs.join(", ")})"
|
|
151
|
+
out << " execute(#{forward}).data!"
|
|
152
|
+
out << " end"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def variable_serialize(var)
|
|
156
|
+
value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
|
|
157
|
+
var.node.serialize_identity? ? value : var.node.serialize(value, 1)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Structured shape for a schema-validation error: message plus its first
|
|
161
|
+
# source location, so ValidationError#errors is inspectable.
|
|
162
|
+
def validation_detail(error)
|
|
163
|
+
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
164
|
+
{ message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def field_cast(field)
|
|
168
|
+
node = field.node
|
|
169
|
+
|
|
170
|
+
if node.non_null?
|
|
171
|
+
raw = "data.fetch(#{field.key.inspect})"
|
|
172
|
+
node.identity? ? raw : node.cast(raw, 1)
|
|
173
|
+
else
|
|
174
|
+
raw = "data[#{field.key.inspect}]"
|
|
175
|
+
node.identity? ? raw : "#{raw}&.then { |v1| #{node.cast("v1", 2)} }"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# a module-level T::Struct per input type; serialize builds the wire
|
|
180
|
+
# hash, omitting optional fields left nil
|
|
181
|
+
def emit_input(node, out, indent)
|
|
182
|
+
pad = " " * indent
|
|
183
|
+
|
|
184
|
+
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
185
|
+
out << "#{pad} extend T::Sig"
|
|
186
|
+
out << ""
|
|
187
|
+
node.fields.each do |field|
|
|
188
|
+
default = field.required ? "" : ", default: nil"
|
|
189
|
+
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
|
|
190
|
+
end
|
|
191
|
+
out << ""
|
|
192
|
+
out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
|
|
193
|
+
out << "#{pad} def serialize"
|
|
194
|
+
out << "#{pad} result = T.let({}, T::Hash[String, T.untyped])"
|
|
195
|
+
node.fields.each do |field|
|
|
196
|
+
value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
|
|
197
|
+
line = "result[#{field.wire.inspect}] = #{value}"
|
|
198
|
+
line += " unless #{field.prop}.nil?" unless field.required
|
|
199
|
+
out << "#{pad} #{line}"
|
|
200
|
+
end
|
|
201
|
+
out << "#{pad} result"
|
|
202
|
+
out << "#{pad} end"
|
|
203
|
+
out << ""
|
|
204
|
+
out << "#{pad} # serialize, under the conventional name"
|
|
205
|
+
out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
|
|
206
|
+
out << "#{pad} def to_h = serialize"
|
|
207
|
+
out << ""
|
|
208
|
+
out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
|
|
209
|
+
out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
|
|
210
|
+
out << "#{pad} # the struct's types are enforced on construction."
|
|
211
|
+
out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
|
|
212
|
+
out << "#{pad} def self.coerce(value)"
|
|
213
|
+
out << "#{pad} return value if value.is_a?(#{node.class_name})"
|
|
214
|
+
out << ""
|
|
215
|
+
out << "#{pad} new("
|
|
216
|
+
node.fields.each do |field|
|
|
217
|
+
raw = "value_at(value, :#{field.prop})"
|
|
218
|
+
expr = if field.node.hash_coerce_identity?
|
|
219
|
+
raw
|
|
220
|
+
elsif field.required
|
|
221
|
+
"#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
|
|
222
|
+
else
|
|
223
|
+
"#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
|
|
224
|
+
end
|
|
225
|
+
out << "#{pad} #{field.prop}: #{expr},"
|
|
226
|
+
end
|
|
227
|
+
out << "#{pad} )"
|
|
228
|
+
out << "#{pad} end"
|
|
229
|
+
out << ""
|
|
230
|
+
out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
|
|
231
|
+
out << "#{pad} private_class_method def self.value_at(hash, key)"
|
|
232
|
+
out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
|
|
233
|
+
out << "#{pad} end"
|
|
234
|
+
out << "#{pad}end"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|