graph_weaver 0.6.0 → 0.6.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 +23 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/docs/getting_started.md +1 -1
- data/docs/real_world.md +4 -4
- data/docs/scalars.md +107 -42
- data/docs/testing.md +2 -2
- data/docs/upgrading.md +2 -2
- data/lib/graph_weaver/codegen/scalar_type.rb +103 -37
- data/lib/graph_weaver/codegen.rb +4 -9
- data/lib/graph_weaver/internal/values.rb +3 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +16 -10
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12bea1d4f3d38c00c496ec595788629ea6c12c9f7590fc7938976570d8c86917
|
|
4
|
+
data.tar.gz: e7f8e60574801cc70032a4e2502a05d6ec8f9ac170ab22b53fa12b4f2633835a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e913ad4e5104a983b7ba0d87ae838de4a6e9e238aa7fc70c3d8d673ee24faba7eecf2734c7d403443cd2acdf93d4723adbc01bc0fc485e738df608f63adf43c1
|
|
7
|
+
data.tar.gz: cc1d344c3f99c5d587a3abb5e385626048d4821766c458ae7a7401b639ab9851c6c253d618282b9e314310bae88583240b0770588b06c898052e7a96d8472975
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
### v0.6.1 (2026-09-10)
|
|
2
|
+
- **A stdlib scalar registers with nothing but its class.**
|
|
3
|
+
`GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
|
|
4
|
+
codec: `BigDecimal(...)` reads the wire value, `to_s("F")` writes it back,
|
|
5
|
+
and the generated file requires `bigdecimal`. The careful spelling people
|
|
6
|
+
reached for was wrong — `serialize: :to_s` puts `"0.125e2"` on the wire —
|
|
7
|
+
so the library owns it. Cast inference gained Kernel's conversion functions
|
|
8
|
+
(`BigDecimal()`, and any `Kernel#Type` your app defines for a type the wire
|
|
9
|
+
can't already be), and `serialize:` accepts `[:method, *args]` for a
|
|
10
|
+
serializer that takes arguments. `cast:`/`serialize:`/`requires:` are
|
|
11
|
+
unchanged, for a class of your own.
|
|
12
|
+
- **Scalar names that are conventions are registered already**: graphql-ruby's
|
|
13
|
+
`ISO8601Date`, `ISO8601DateTime`, `BigInt` and `JSON`, plus `DateTime` —
|
|
14
|
+
what GitHub, Shopify and most hand-written schemas call an ISO 8601
|
|
15
|
+
timestamp. A date deserializes into `Date` and a timestamp into `Time`, so
|
|
16
|
+
nothing invents a midnight; `BigInt` reads the decimal string graphql-ruby
|
|
17
|
+
writes as well as a JSON number; `JSON` is registered as `T.untyped` on
|
|
18
|
+
purpose, which also drops it from the "unregistered custom scalars" report.
|
|
19
|
+
A schema that means something else by one of these names fails loudly — the
|
|
20
|
+
cast raises, naming the field — and one `register_scalar` overrides it, as
|
|
21
|
+
it always could. **Regenerate** if a query of yours reads a scalar with one
|
|
22
|
+
of those names: its prop was `T.untyped` and now has a type.
|
|
23
|
+
|
|
1
24
|
### v0.6.0 (2026-09-09)
|
|
2
25
|
- **One rule for relative path settings: they resolve against
|
|
3
26
|
`GraphWeaver.root`** — `Rails.root` in a Rails app, the working directory
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.6.
|
|
4
|
+
graph_weaver (0.6.1)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -176,7 +176,7 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.6.
|
|
179
|
+
graph_weaver (0.6.1)
|
|
180
180
|
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
data/README.md
CHANGED
|
@@ -175,7 +175,7 @@ upgrade, and `verify` is what tells you when you've skipped it.
|
|
|
175
175
|
- **[Federation](docs/federation.md)** — supergraph vs API schema, the local router, what it refuses
|
|
176
176
|
- **[Transports](docs/transports.md)** — the execute contract, Faraday, retries and backoff
|
|
177
177
|
- **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy, field-level reports
|
|
178
|
-
- **[Custom scalars](docs/scalars.md)** —
|
|
178
|
+
- **[Custom scalars](docs/scalars.md)** — what's registered already, codec inference, input coercion
|
|
179
179
|
- **[Cassettes](docs/cassettes.md)** — capture and replay real responses, anonymized
|
|
180
180
|
- **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
|
|
181
181
|
- **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
|
data/docs/getting_started.md
CHANGED
|
@@ -58,7 +58,7 @@ What it wrote:
|
|
|
58
58
|
into generated source, so they have to run first:
|
|
59
59
|
|
|
60
60
|
```ruby
|
|
61
|
-
GraphWeaver.register_scalar("
|
|
61
|
+
GraphWeaver.register_scalar("Money", Money) # a scalar the registry can't know
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
A registration that names one of your own constants — a `T::Enum` for
|
data/docs/real_world.md
CHANGED
|
@@ -19,10 +19,10 @@ require "graph_weaver"
|
|
|
19
19
|
# in a header, so a stale dump says where it came from.
|
|
20
20
|
github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
22
|
+
# GitHub's DateTime needs no registration — it is one of the names the
|
|
23
|
+
# registry knows (docs/scalars.md). A scalar of your own would go here;
|
|
24
|
+
# registrations are global and codegen-time, so one line types your
|
|
25
|
+
# console and your checked-in code identically.
|
|
26
26
|
|
|
27
27
|
RepoQuery = github.parse(<<~GRAPHQL)
|
|
28
28
|
query($owner: String!, $name: String!) {
|
data/docs/scalars.md
CHANGED
|
@@ -2,74 +2,136 @@
|
|
|
2
2
|
|
|
3
3
|
Teach the generator how a GraphQL custom scalar deserializes into a rich
|
|
4
4
|
Ruby object (and serializes back when used as a variable). A field typed
|
|
5
|
-
`
|
|
6
|
-
`
|
|
5
|
+
`Decimal` then generates `const :price, T.nilable(BigDecimal)` and casts with
|
|
6
|
+
`BigDecimal(...)` inline — no runtime reflection:
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
|
-
GraphWeaver.register_scalar("
|
|
9
|
+
GraphWeaver.register_scalar("Decimal", BigDecimal)
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Two arguments: the scalar's name in your schema, and the Ruby type it means.
|
|
13
|
+
The second is the only part the library can't work out — how a wire value
|
|
14
|
+
becomes a `BigDecimal`, how one goes back on the wire, and the
|
|
15
|
+
`require "bigdecimal"` the generated file needs are all inferred.
|
|
16
|
+
|
|
12
17
|
Registration is global and codegen-time: `rake graph_weaver:generate` reads the
|
|
13
18
|
same registry an initializer writes, so register before you generate.
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
that one field — so the same scalar can deserialize as different Ruby types
|
|
17
|
-
across fields:
|
|
20
|
+
## Already registered
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
```
|
|
22
|
+
These names need no registration. graphql-ruby ships all but `DateTime` as its
|
|
23
|
+
own scalars, and `DateTime` is what GitHub, Shopify and most hand-written
|
|
24
|
+
schemas call an ISO 8601 timestamp.
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
| scalar | Ruby type | on the wire |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `ID` | `String` | the string (an `Integer` input is accepted) |
|
|
29
|
+
| `String` | `String` | the string |
|
|
30
|
+
| `Int` | `Integer` | a JSON integer |
|
|
31
|
+
| `Float` | `Float` | any JSON number |
|
|
32
|
+
| `Boolean` | `T::Boolean` | the boolean |
|
|
33
|
+
| `Date`, `ISO8601Date` | `Date` | `"2024-01-15"` |
|
|
34
|
+
| `DateTime`, `ISO8601DateTime` | `Time` | `"2024-01-15T10:20:30Z"` |
|
|
35
|
+
| `BigInt` | `Integer` | the decimal string graphql-ruby writes; a JSON number is read too |
|
|
36
|
+
| `JSON` | `T.untyped` | whatever it is, untouched |
|
|
37
|
+
|
|
38
|
+
A date stays a `Date` and a timestamp a `Time`, deliberately: casting a date to
|
|
39
|
+
`Time` invents a midnight the server never sent. A schema that means something
|
|
40
|
+
else by one of these names fails loudly — the cast raises, naming the field —
|
|
41
|
+
and one `register_scalar` overrides it, like any other entry. Names that are
|
|
42
|
+
*not* a convention (`Timestamp`, `UUID`, `URL`, `Decimal`, `Money`) are left to
|
|
43
|
+
you, because guessing at one would be worse than asking.
|
|
44
|
+
|
|
45
|
+
## Registering a stdlib type
|
|
46
|
+
|
|
47
|
+
Name the class and stop. What the library supplies is the part inference can't
|
|
48
|
+
reach: the wire spelling (`BigDecimal#to_s` writes `"0.125e2"`, which is not
|
|
49
|
+
what any server means by 12.5, and `Date.parse` reads far more than the ISO
|
|
50
|
+
8601 a `Date` scalar carries) and the file to require, so the generated source
|
|
51
|
+
stands alone.
|
|
52
|
+
|
|
53
|
+
| Ruby type | cast | serialize | require |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `BigDecimal` | `BigDecimal(v)` | `v.to_s("F")` | `bigdecimal` |
|
|
56
|
+
| `Date` | `Date.iso8601(v)` | `v.iso8601` | `date` |
|
|
57
|
+
| `Time` | `Time.parse(v)` | `v.iso8601` | `time` |
|
|
58
|
+
| `DateTime` | `DateTime.iso8601(v)` | `v.iso8601` | `date` |
|
|
26
59
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
only warns — one registry serves a whole graph, so that name may belong to the
|
|
32
|
-
subgraph next door (see
|
|
33
|
-
[federation](federation.md#generating-for-a-federated-graph)).
|
|
60
|
+
For a timestamp, reach for `Time`; Ruby's own `DateTime` is accepted if you
|
|
61
|
+
register it, but never assumed. `BigDecimal(v)` is Ruby's own reader, so
|
|
62
|
+
it takes what Ruby takes — `"12.5"`, `"1e3"`, a JSON number — and refuses
|
|
63
|
+
`"abc"` or `"$12.50"`, naming the field or the variable.
|
|
34
64
|
|
|
35
|
-
|
|
36
|
-
**inferred** from it by probing the deserialize side and pairing its serializer:
|
|
65
|
+
## Registering a class of your own
|
|
37
66
|
|
|
38
|
-
|
|
67
|
+
Pass the class and the cast/serialize are **inferred** from it, by probing the
|
|
68
|
+
deserialize side and pairing its serializer:
|
|
69
|
+
|
|
70
|
+
| the class defines | cast | serialize |
|
|
39
71
|
|-------------------|---------------|----------------|
|
|
40
|
-
| `.parse`
|
|
41
|
-
| `.load`
|
|
72
|
+
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
73
|
+
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
74
|
+
| `Kernel#Type` | `Type(v)` | — |
|
|
75
|
+
|
|
76
|
+
so a value object with a `.parse` needs nothing more:
|
|
42
77
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
78
|
+
```ruby
|
|
79
|
+
GraphWeaver.register_scalar("Money", Money)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A type defining none of those stays pass-through rather than getting wrapped —
|
|
83
|
+
every object has `#to_s`, so inferring a serializer off it would wrap plain
|
|
84
|
+
types too. Override explicitly when you need to:
|
|
46
85
|
|
|
47
86
|
- a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
|
|
48
87
|
`serialize: :to_json` → `expr.to_json`
|
|
88
|
+
- an `Array`, for a method with arguments: `serialize: [:to_s, "F"]` → `expr.to_s("F")`
|
|
49
89
|
- a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
|
|
50
90
|
- `:itself` to force pass-through, opting out of inference (rare)
|
|
51
91
|
|
|
52
|
-
The type also accepts a plain string (`"
|
|
92
|
+
The type also accepts a plain string (`"Money"`) when you'd rather not
|
|
53
93
|
reference the class. `requires:` (a string or array) names files emitted as
|
|
54
94
|
`require`s atop the generated source so the cast/type resolve. When the type is
|
|
55
95
|
a real class (so the runtime is loaded), each path is also `require`d at
|
|
56
96
|
registration — a typo fails now, not in the generated file.
|
|
57
97
|
|
|
98
|
+
## Overriding one field
|
|
99
|
+
|
|
100
|
+
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
101
|
+
that one field — so the same scalar can deserialize as different Ruby types
|
|
102
|
+
across fields:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
GraphWeaver.register_scalar("Timestamp", Time) # the default, everywhere
|
|
106
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A field override wins over the scalar-name registration — which is also how two
|
|
110
|
+
servers that disagree about a `DateTime` coexist in one process.
|
|
111
|
+
|
|
112
|
+
Registrations are validated against the schema you generate against, and only
|
|
113
|
+
what that schema can **disprove** fails generation: a name it declares as
|
|
114
|
+
something else (`register_scalar("Species")` where `Species` is an enum), or a
|
|
115
|
+
coordinate whose field it declares as a composite. A name it simply can't match
|
|
116
|
+
only warns — one registry serves a whole graph, so that name may belong to the
|
|
117
|
+
subgraph next door (see
|
|
118
|
+
[federation](federation.md#generating-for-a-federated-graph)).
|
|
119
|
+
|
|
58
120
|
The testing harness can't invent a wire value for a scalar registered as your
|
|
59
121
|
own class — only `Money.parse` knows what it accepts — so it refuses rather than
|
|
60
122
|
guess. Say it in test config, where that answer belongs: a pin for the type,
|
|
61
123
|
`GraphWeaver::Testing.config.overrides = { "Money" => "12.00" }`, or per example
|
|
62
|
-
([testing → pins](testing.md#pins)). A scalar registered as
|
|
63
|
-
`
|
|
124
|
+
([testing → pins](testing.md#pins)). A scalar registered as one of the types
|
|
125
|
+
above — `BigDecimal`, `Time`, `Date`, `Integer`, `Float`, `String`,
|
|
126
|
+
`T::Boolean` — needs nothing.
|
|
64
127
|
|
|
65
|
-
A registration whose type is a class **JSON can't parse into
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
naming the field:
|
|
128
|
+
A registration whose type is a class **JSON can't parse into**, with nothing to
|
|
129
|
+
build one, is refused where a query reads that scalar back: the prop would be
|
|
130
|
+
unsatisfiable for every response, and finding that out at runtime is worse.
|
|
131
|
+
Generation names the field:
|
|
70
132
|
|
|
71
133
|
```
|
|
72
|
-
register_scalar("Money",
|
|
134
|
+
register_scalar("Money", Wallet) has no cast, so nothing builds a Wallet
|
|
73
135
|
out of the JSON at Product.price — give it one ...
|
|
74
136
|
```
|
|
75
137
|
|
|
@@ -91,9 +153,6 @@ stays as narrow as the schema and the conversion happens in its body (see
|
|
|
91
153
|
error — as it should be, since you have a `Money` right there — while
|
|
92
154
|
`budget: params[:budget]` typechecks and converts.
|
|
93
155
|
|
|
94
|
-
The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
|
|
95
|
-
same path (`Date` even carries its own `require "date"`), so a later
|
|
96
|
-
`register_scalar` overrides them.
|
|
97
156
|
|
|
98
157
|
## What the wire carries
|
|
99
158
|
|
|
@@ -119,8 +178,9 @@ server writing a non-integer where the spec says integer, so it is refused.
|
|
|
119
178
|
| `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
|
|
120
179
|
| `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
|
|
121
180
|
| `DateTime`/`Time` (registered as `Time`) | RFC 3339 with `Z` or an offset, with or without fractional seconds, seconds optional; also a bare date and `Time.parse`'s looser forms | an epoch integer, an unparseable string |
|
|
181
|
+
| `BigInt` | the decimal string graphql-ruby writes, past 2⁵³ included; also a JSON integer | `1.5`, `"1.5"`, a non-numeric string, `true` |
|
|
122
182
|
| an enum | a declared value, as a string | an undeclared value, a non-string |
|
|
123
|
-
| unregistered | anything — `T.untyped`, straight through | nothing |
|
|
183
|
+
| `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
|
|
124
184
|
|
|
125
185
|
A refusal is a [`GraphWeaver::TypeError`](errors.md) naming the field and the
|
|
126
186
|
generated struct (which names the query). Two refusals carry advice rather than
|
|
@@ -148,10 +208,11 @@ the sig is `.checked(:never)`).
|
|
|
148
208
|
| `Boolean` | `true`/`false` | nothing | the boolean |
|
|
149
209
|
| `Date` | `Date` | an ISO-8601 string | `iso8601` |
|
|
150
210
|
| `Time` | `Time` | a string `Time.parse` takes | `iso8601` |
|
|
211
|
+
| `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
|
|
151
212
|
| an enum | the member **or** its wire value | — | the wire value |
|
|
152
213
|
| an input object | the struct **or** a Hash | — | the wire hash |
|
|
153
|
-
| a registered custom scalar | its Ruby type | whatever its
|
|
154
|
-
| unregistered | `T.untyped` | anything | straight through |
|
|
214
|
+
| a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
|
|
215
|
+
| `JSON`, or unregistered | `T.untyped` | anything | straight through |
|
|
155
216
|
|
|
156
217
|
Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
|
|
157
218
|
the GraphQL spec says an ID serializes as a string but accepts an integer input,
|
|
@@ -183,6 +244,10 @@ otherwise exact result type, so generation names the holes at `info` (see
|
|
|
183
244
|
3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
|
|
184
245
|
```
|
|
185
246
|
|
|
247
|
+
A scalar that is *meant* to be untyped belongs in the registry too —
|
|
248
|
+
`GraphWeaver.register_scalar("Json", "T.untyped")` says so once, and it leaves
|
|
249
|
+
the report. `JSON` is registered that way already.
|
|
250
|
+
|
|
186
251
|
## Enums: map onto your own T::Enum
|
|
187
252
|
|
|
188
253
|
By default a schema enum generates one `T::Enum` per schema, shared by every
|
data/docs/testing.md
CHANGED
|
@@ -181,8 +181,8 @@ A **scalar type pin** is the one thing a scalar registered as *your own class*
|
|
|
181
181
|
needs — only `Money.parse` knows what wire value it accepts — so without one
|
|
182
182
|
fabrication refuses at the path it reached (`at reader.orders.0.total`) and
|
|
183
183
|
names the pin to add, rather than feeding your cast a placeholder that fails
|
|
184
|
-
deep inside `from_h`. A scalar registered as `
|
|
185
|
-
`Float`, `String` or `T::Boolean` needs nothing. Suite-wide, the same hash is
|
|
184
|
+
deep inside `from_h`. A scalar registered as `BigDecimal`, `Time`, `Date`,
|
|
185
|
+
`Integer`, `Float`, `String` or `T::Boolean` needs nothing. Suite-wide, the same hash is
|
|
186
186
|
`config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
|
|
187
187
|
|
|
188
188
|
Pins lead and options follow — `graphql_fake("Money" => "12.00", values:
|
data/docs/upgrading.md
CHANGED
|
@@ -163,8 +163,8 @@ are emitted `private_constant`, so **regenerate**.
|
|
|
163
163
|
holding a registered custom scalar: the anonymizer used to write a value the
|
|
164
164
|
generated codec couldn't read back.
|
|
165
165
|
- **Generation refuses four more things**, each naming its fix — a
|
|
166
|
-
`register_scalar` whose Ruby type nothing can build out of JSON (
|
|
167
|
-
|
|
166
|
+
`register_scalar` whose Ruby type nothing can build out of JSON (a value
|
|
167
|
+
object of your own: give it a `cast:`), a result key that would shadow a constant the
|
|
168
168
|
file uses, an enum value that camelizes to nothing, and a narrowed fragment
|
|
169
169
|
whose `__typename` sits behind `@skip`/`@include`.
|
|
170
170
|
|
|
@@ -12,10 +12,12 @@ class GraphWeaver::Codegen
|
|
|
12
12
|
# scalars and overrides go through the same path.
|
|
13
13
|
#
|
|
14
14
|
# cast/serialize normalize to procs that, given a Ruby expression string,
|
|
15
|
-
# return the code to inline. Left nil (the default) they are inferred
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# return the code to inline. Left nil (the default) they are inferred from
|
|
16
|
+
# the Ruby type: a type the library already knows takes its codec from
|
|
17
|
+
# STDLIB, otherwise it is probed for a known deserializer whose serializer
|
|
18
|
+
# pairs with it (see CODECS), and failing that for a Kernel conversion
|
|
19
|
+
# function of its own name — so the common case needs no more than a class:
|
|
20
|
+
# type: BigDecimal (Kernel#BigDecimal) => BigDecimal(expr) / expr.to_s("F")
|
|
19
21
|
# type: Money (defines .parse) => Money.parse(expr) / expr.to_s
|
|
20
22
|
# type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
|
|
21
23
|
# Probing the *deserialize* side is deliberate: every object has #to_s,
|
|
@@ -25,6 +27,7 @@ class GraphWeaver::Codegen
|
|
|
25
27
|
# - a Symbol names a method, so there is no string to misspell:
|
|
26
28
|
# cast: :load => "Blob.load(expr)" (class method on type)
|
|
27
29
|
# serialize: :to_json => "expr.to_json" (instance method)
|
|
30
|
+
# - an Array is that method with arguments: serialize: [:to_s, "F"]
|
|
28
31
|
# - a Proc handles anything a Symbol can't express:
|
|
29
32
|
# cast: ->(e) { "Money.new(#{e})" }
|
|
30
33
|
# - :itself opts out — force identity pass-through even when a codec
|
|
@@ -59,7 +62,29 @@ class GraphWeaver::Codegen
|
|
|
59
62
|
"String" => "string",
|
|
60
63
|
"T::Boolean" => "boolean",
|
|
61
64
|
}.freeze
|
|
62
|
-
|
|
65
|
+
|
|
66
|
+
# What the library already knows about a Ruby type, so registering one
|
|
67
|
+
# takes nothing but the class. Consulted only where the registration is
|
|
68
|
+
# silent; an explicit cast:/serialize:/requires: wins. Two things the
|
|
69
|
+
# probe above can't work out on its own:
|
|
70
|
+
# - the wire spelling. BigDecimal#to_s writes "0.125e2", which is not
|
|
71
|
+
# what any server means by 12.5, and Date.parse reads a great deal
|
|
72
|
+
# more than the ISO 8601 a Date scalar carries.
|
|
73
|
+
# - the file to require, so the generated source stands alone.
|
|
74
|
+
# Only types whose wire form is unambiguous belong here.
|
|
75
|
+
STDLIB = {
|
|
76
|
+
"BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
|
|
77
|
+
"Date" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
|
|
78
|
+
"Time" => { cast: :parse, serialize: :iso8601, requires: "time" },
|
|
79
|
+
"DateTime" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
# Everything JSON.parse can hand back. A registered type outside this set
|
|
83
|
+
# has to be BUILT from one of them, which is what a cast is for (and what
|
|
84
|
+
# Codegen#refuse_uncastable! insists on).
|
|
85
|
+
WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
|
|
86
|
+
|
|
87
|
+
private_constant :Codec, :CODECS, :COERCERS, :STDLIB
|
|
63
88
|
|
|
64
89
|
attr_reader :graphql_name, :type, :requires
|
|
65
90
|
|
|
@@ -67,14 +92,22 @@ class GraphWeaver::Codegen
|
|
|
67
92
|
@graphql_name = graphql_name.to_s
|
|
68
93
|
@klass = type.is_a?(Module) ? type : nil
|
|
69
94
|
@type = type_name(type)
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
# stdlib loads
|
|
73
|
-
|
|
95
|
+
known = STDLIB[@type] || {}
|
|
96
|
+
# requires: load BEFORE probing — the deserializer may arrive with the
|
|
97
|
+
# file (core Time has no .parse until the "time" stdlib loads, and
|
|
98
|
+
# Kernel#BigDecimal none until "bigdecimal" does). A path from STDLIB
|
|
99
|
+
# is the library's own, so it loads even for a type: given as a String,
|
|
100
|
+
# whose dependency we otherwise can't assume is installed.
|
|
101
|
+
@requires =
|
|
102
|
+
if requires.nil?
|
|
103
|
+
GraphWeaver::Codegen.normalize_requires!(known[:requires], load: true)
|
|
104
|
+
else
|
|
105
|
+
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
106
|
+
end
|
|
74
107
|
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
75
|
-
@cast = normalize_cast(cast, codec&.cast)
|
|
76
|
-
@serialize = normalize_serialize(serialize, codec&.serialize)
|
|
77
|
-
@serialize_value = runtime_serialize(serialize, codec)
|
|
108
|
+
@cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
|
|
109
|
+
@serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
|
|
110
|
+
@serialize_value = runtime_serialize(serialize || known[:serialize], codec)
|
|
78
111
|
end
|
|
79
112
|
|
|
80
113
|
def cast(expr) = @cast&.call(expr)
|
|
@@ -126,6 +159,17 @@ class GraphWeaver::Codegen
|
|
|
126
159
|
end
|
|
127
160
|
end
|
|
128
161
|
|
|
162
|
+
# Kernel's conversion functions are how a wire value becomes one of these
|
|
163
|
+
# — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
|
|
164
|
+
# read a decimal string all along. Only for a type the wire can't already
|
|
165
|
+
# be: Kernel#String and Kernel#Array wrap a value rather than convert it.
|
|
166
|
+
def kernel_cast
|
|
167
|
+
return unless Kernel.private_method_defined?(@type.to_sym)
|
|
168
|
+
return if WIRE_CLASSES.any? { |native| native.name == @type }
|
|
169
|
+
|
|
170
|
+
->(type, expr) { "#{type}(#{expr})" }
|
|
171
|
+
end
|
|
172
|
+
|
|
129
173
|
# nil infers via the matched codec; :itself opts out (identity); a
|
|
130
174
|
# Symbol is a class method on the type — Money.parse(expr)
|
|
131
175
|
def normalize_cast(cast, inferred)
|
|
@@ -138,33 +182,37 @@ class GraphWeaver::Codegen
|
|
|
138
182
|
end
|
|
139
183
|
end
|
|
140
184
|
|
|
141
|
-
# nil infers via the matched codec; :itself opts out (identity); a
|
|
142
|
-
#
|
|
185
|
+
# nil infers via the matched codec; :itself opts out (identity); a Symbol
|
|
186
|
+
# is an instance method on the value — expr.to_s — and an Array is that
|
|
187
|
+
# method with arguments: [:to_s, "F"] => expr.to_s("F")
|
|
143
188
|
def normalize_serialize(serialize, inferred)
|
|
144
189
|
case serialize
|
|
145
190
|
when :itself then nil
|
|
146
191
|
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
147
192
|
when Proc then serialize
|
|
148
193
|
when Symbol then ->(expr) { "#{expr}.#{serialize}" }
|
|
149
|
-
|
|
194
|
+
when Array
|
|
195
|
+
method, *args = serialize
|
|
196
|
+
unless method.is_a?(Symbol)
|
|
197
|
+
# a syntax error in the generated file otherwise
|
|
198
|
+
raise ArgumentError, "serialize: an Array is [method, *arguments], got #{serialize.inspect}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
->(expr) { "#{expr}.#{method}(#{args.map(&:inspect).join(", ")})" }
|
|
202
|
+
else raise ArgumentError, "serialize: must be a Symbol, Array, Proc, :itself, or nil, got #{serialize.inspect}"
|
|
150
203
|
end
|
|
151
204
|
end
|
|
152
205
|
|
|
153
|
-
# The runnable half of normalize_serialize: a Symbol
|
|
154
|
-
#
|
|
155
|
-
# knows its own call, and a Proc emits code there
|
|
206
|
+
# The runnable half of normalize_serialize: a Symbol (or Symbol with
|
|
207
|
+
# arguments) names a method — :itself included, which is identity either
|
|
208
|
+
# way — an inferred codec knows its own call, and a Proc emits code there
|
|
209
|
+
# is no way to run.
|
|
156
210
|
def runtime_serialize(serialize, codec)
|
|
157
211
|
case serialize
|
|
158
|
-
when Symbol then ->(value) { value.public_send(serialize) }
|
|
212
|
+
when Symbol, Array then ->(value) { value.public_send(*serialize) }
|
|
159
213
|
when nil then codec && ->(value) { codec.call.call(@klass, value) }
|
|
160
214
|
end
|
|
161
215
|
end
|
|
162
|
-
|
|
163
|
-
# With only a type-name string we can't assume the lib is installed at
|
|
164
|
-
# codegen time, so the paths aren't loaded — only shape-checked.
|
|
165
|
-
def normalize_requires(requires)
|
|
166
|
-
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
167
|
-
end
|
|
168
216
|
end
|
|
169
217
|
|
|
170
218
|
class << self
|
|
@@ -230,24 +278,42 @@ class GraphWeaver::Codegen
|
|
|
230
278
|
self
|
|
231
279
|
end
|
|
232
280
|
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
281
|
+
# Pre-registered scalars — ordinary entries in the one registry, so a
|
|
282
|
+
# later register_scalar overrides any of them.
|
|
283
|
+
#
|
|
284
|
+
# The five the spec names stay pass-through: their Ruby classes (String,
|
|
285
|
+
# Integer) define neither .parse nor .load, so inference matches nothing
|
|
286
|
+
# and leaves them identity — which is exactly why we can name them with
|
|
287
|
+
# the real class constants. Float is the exception: JSON has one number
|
|
288
|
+
# type, so a whole Float arrives as `1` from every encoder that drops the
|
|
289
|
+
# trailing zero (graphql-js and Go both do), and Coerce.float widens that
|
|
290
|
+
# without accepting the garbage `.to_f` would silently turn into 0.0.
|
|
291
|
+
#
|
|
292
|
+
# The rest are names, not guesses: graphql-ruby ships all but DateTime as
|
|
293
|
+
# its own scalars, and this library runs a graphql-ruby schema in-process.
|
|
294
|
+
# DateTime is what GitHub, Shopify and most hand-written schemas call an
|
|
295
|
+
# ISO 8601 timestamp; a schema that means something else by it fails
|
|
296
|
+
# loudly (the cast raises, naming the field) and is one register_scalar
|
|
297
|
+
# away. Date and datetime are told apart by their Ruby type — a Date cast
|
|
298
|
+
# to Time would invent a midnight the server never sent.
|
|
244
299
|
def register_builtin_scalars!
|
|
245
300
|
register_scalar "ID", String
|
|
246
301
|
register_scalar "String", String
|
|
247
302
|
register_scalar "Int", Integer
|
|
248
303
|
register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
|
|
249
304
|
register_scalar "Boolean", "T::Boolean"
|
|
250
|
-
register_scalar "Date", Date
|
|
305
|
+
register_scalar "Date", Date
|
|
306
|
+
register_scalar "ISO8601Date", Date
|
|
307
|
+
register_scalar "ISO8601DateTime", Time
|
|
308
|
+
register_scalar "DateTime", Time
|
|
309
|
+
# graphql-ruby writes a BigInt as a string, since JSON numbers stop
|
|
310
|
+
# being exact at 2^53 — so read either spelling and write the one the
|
|
311
|
+
# server does.
|
|
312
|
+
register_scalar "BigInt", Integer,
|
|
313
|
+
cast: ->(expr) { "GraphWeaver::Coerce.integer(#{expr})" }, serialize: :to_s
|
|
314
|
+
# untyped on purpose: registering it says so, rather than leaving JSON
|
|
315
|
+
# in the "unregistered custom scalars" report every generation
|
|
316
|
+
register_scalar "JSON", "T.untyped"
|
|
251
317
|
end
|
|
252
318
|
private :register_builtin_scalars!
|
|
253
319
|
end
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -1285,21 +1285,16 @@ class GraphWeaver::Codegen
|
|
|
1285
1285
|
Scalar.new(scalar)
|
|
1286
1286
|
end
|
|
1287
1287
|
|
|
1288
|
-
# Everything JSON.parse can hand back. A registered type outside this set
|
|
1289
|
-
# has to be BUILT from one of them, which is what cast: is for.
|
|
1290
|
-
WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
|
|
1291
|
-
private_constant :WIRE_CLASSES
|
|
1292
|
-
|
|
1293
1288
|
# A registered type nothing on the wire can be, with no cast to build one:
|
|
1294
1289
|
# the prop is unsatisfiable, so every response fails — at runtime, a long
|
|
1295
|
-
# way from the registration that caused it.
|
|
1296
|
-
#
|
|
1297
|
-
#
|
|
1290
|
+
# way from the registration that caused it. Reaches an app's own value
|
|
1291
|
+
# object, since the stdlib types people reach for (BigDecimal, Time) infer
|
|
1292
|
+
# a cast; see ScalarType.
|
|
1298
1293
|
def refuse_uncastable!(scalar, where)
|
|
1299
1294
|
return if scalar.cast?
|
|
1300
1295
|
|
|
1301
1296
|
klass = Object.const_get(scalar.type)
|
|
1302
|
-
return unless klass.is_a?(Class) && WIRE_CLASSES.none? { |native| native <= klass }
|
|
1297
|
+
return unless klass.is_a?(Class) && ScalarType::WIRE_CLASSES.none? { |native| native <= klass }
|
|
1303
1298
|
|
|
1304
1299
|
raise GraphWeaver::Error,
|
|
1305
1300
|
"register_scalar(#{scalar.graphql_name.inspect}, #{scalar.type}) has no cast, so nothing " \
|
|
@@ -44,6 +44,7 @@ class GraphWeaver::Internal::Values
|
|
|
44
44
|
"Date" => :date,
|
|
45
45
|
"Time" => :time,
|
|
46
46
|
"DateTime" => :time,
|
|
47
|
+
"BigDecimal" => :decimal,
|
|
47
48
|
}.freeze
|
|
48
49
|
|
|
49
50
|
# What Codegen.scalar reports for a scalar nobody registered
|
|
@@ -113,6 +114,8 @@ class GraphWeaver::Internal::Values
|
|
|
113
114
|
when :integer then @rng.rand(0..1_000)
|
|
114
115
|
when :float then @rng.rand(0.0..1_000.0).round(2)
|
|
115
116
|
when :boolean then [true, false].sample(random: @rng)
|
|
117
|
+
# the plain-notation string BigDecimal() reads and #to_s("F") writes
|
|
118
|
+
when :decimal then format("%.2f", @rng.rand(0.0..10_000.0))
|
|
116
119
|
when :date then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
|
|
117
120
|
when :time then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
|
|
118
121
|
when :unregistered then "#{type_name}-#{@sequence += 1}" # nobody registered it: prop is T.untyped
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -620,20 +620,26 @@ module GraphWeaver
|
|
|
620
620
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
621
621
|
# variable):
|
|
622
622
|
#
|
|
623
|
-
# GraphWeaver.register_scalar("
|
|
623
|
+
# GraphWeaver.register_scalar("Decimal", BigDecimal)
|
|
624
|
+
# GraphWeaver.register_scalar("Money", Money)
|
|
624
625
|
#
|
|
625
626
|
# A field typed `Money` then generates `const :price, T.nilable(Money)`
|
|
626
|
-
# and casts with `Money.parse(...)` in from_h. Pass a real class as
|
|
627
|
-
#
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
627
|
+
# and casts with `Money.parse(...)` in from_h. Pass a real class as type:
|
|
628
|
+
# and cast:/serialize: are inferred from it: a stdlib type the library
|
|
629
|
+
# knows (BigDecimal, Date, Time) brings its own codec and require, and
|
|
630
|
+
# anything else is probed on the deserialize side — .parse/#to_s,
|
|
631
|
+
# .load/.dump, or a Kernel conversion of its name (see ScalarType).
|
|
632
|
+
# Override with a Symbol method name (safest — no string to misspell),
|
|
633
|
+
# an Array for a method with arguments ([:to_s, "F"]), a Proc(expr) =>
|
|
634
|
+
# code string, or :itself to force pass-through. requires: (a String or
|
|
635
|
+
# Array) names files the generated code needs — validated, and actually
|
|
636
|
+
# required to confirm it resolves when type: is a real class.
|
|
633
637
|
# cast: is also what an untyped variable input coerces through, so a
|
|
634
638
|
# variable of this scalar takes the value OR its raw input ("12.00") with
|
|
635
|
-
# no static loosening — see GraphWeaver::Coerce.
|
|
636
|
-
#
|
|
639
|
+
# no static loosening — see GraphWeaver::Coerce. The spec's own scalars
|
|
640
|
+
# and the conventional names (ISO8601Date, ISO8601DateTime, DateTime,
|
|
641
|
+
# BigInt, JSON) are pre-registered the same way, so this also overrides
|
|
642
|
+
# them — see Codegen::BUILTIN_SCALARS and docs/scalars.md.
|
|
637
643
|
#
|
|
638
644
|
# A scalar registered as a class of your own is the one value the testing
|
|
639
645
|
# harness can't invent — only your `cast:` knows what it accepts — so it
|