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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3847d3890b566828d7bfe496a6523b1032036d6aad4df0959f0b940bddb14d9b
4
- data.tar.gz: d2a38677d13f8a0f2a92c67c9082a1a9523add9429bf4388419dbabc8959302f
3
+ metadata.gz: 12bea1d4f3d38c00c496ec595788629ea6c12c9f7590fc7938976570d8c86917
4
+ data.tar.gz: e7f8e60574801cc70032a4e2502a05d6ec8f9ac170ab22b53fa12b4f2633835a
5
5
  SHA512:
6
- metadata.gz: 118114092cf2cd436e8189fb79dd5be733aa25e34d0aa727da9247e87bc5e309b2b3692248ffa9e40832bf743d672859db444c3fcffc4fc1cbb263b6c395b7e4
7
- data.tar.gz: c0846cca1819d0e2b75d98f4b8d6a74af2f29113686998bd9e616a7d7d1e9a3a70edbe985412c4750a8ec63f0f0c4389e6363c1bc9d58b251a10b2f652d10e0b
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.0)
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.0)
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)** — the registry: codec inference, requires, input coercion
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
@@ -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("DateTime", Time, serialize: :iso8601, requires: "time")
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
- # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse).
23
- # Registrations are global and codegen-time, so this line types your
24
- # console and your checked-in code identically (docs/scalars.md).
25
- GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
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
- `Money` then generates `const :price, T.nilable(Money)` and casts with
6
- `Money.parse(...)` inline — no runtime reflection:
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("Money", Money, requires: "bigdecimal")
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
- Pass a `Type.field` **coordinate** instead of a scalar name to override just
16
- that one field — so the same scalar can deserialize as different Ruby types
17
- across fields:
20
+ ## Already registered
18
21
 
19
- ```ruby
20
- GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
21
- GraphWeaver.register_scalar("User.birthday", Date) # this field only
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
- A field override wins over the scalar-name registration which is also how two
25
- servers that disagree about a `DateTime` coexist in one process.
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
- Registrations are validated against the schema you generate against, and only
28
- what that schema can **disprove** fails generation: a name it declares as
29
- something else (`register_scalar("Species")` where `Species` is an enum), or a
30
- coordinate whose field it declares as a composite. A name it simply can't match
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
- Pass a real class as the second argument and the cast/serialize are
36
- **inferred** from it by probing the deserialize side and pairing its serializer:
65
+ ## Registering a class of your own
37
66
 
38
- | the class defines | cast | serialize |
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` | `Type.parse(v)` | `v.to_s` |
41
- | `.load` | `Type.load(v)` | `Type.dump(v)` |
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
- so the common case needs nothing more. A type defining neither `.parse` nor
44
- `.load` stays pass-through rather than getting wrapped. Override explicitly when
45
- you need to:
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 (`"BigDecimal"`) when you'd rather not
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 `Time`, `Date`,
63
- `Integer`, `Float`, `String` or `T::Boolean` needs nothing.
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** needs a
66
- `cast:` to build one `BigDecimal` is the one people reach for, and it defines
67
- neither `.parse` nor `.load`, so inference finds no codec and the prop would be
68
- unsatisfiable. Generation refuses it where a query reads that scalar back,
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", BigDecimal) has no cast, so nothing builds a BigDecimal
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 `cast:` takes | its `serialize:` |
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 `Time`, `Date`, `Integer`,
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 (`BigDecimal`,
167
- classically: give it a `cast:`), a result key that would shadow a constant the
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
- # from the Ruby type when it is a real class, by probing for a known
17
- # deserializer and pairing its serializer (see CODECS) so the common
18
- # case needs no more than a class:
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
- private_constant :Codec, :CODECS, :COERCERS
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
- # requires: load BEFORE codec probing — the probe method may come
71
- # from the required file (core Time has no .parse until the "time"
72
- # stdlib loads)
73
- @requires = normalize_requires(requires)
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
- # Symbol is an instance method on the value — expr.to_s
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
- else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
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 names a method
154
- # (:itself included, which is identity either way), an inferred codec
155
- # knows its own call, and a Proc emits code there is no way to run.
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
- # Built-in scalars — pre-registered entries in the one registry. Most stay
234
- # pass-through: their Ruby classes (String, Integer) define neither .parse
235
- # nor .load, so codec inference matches nothing and leaves them identity —
236
- # which is exactly why we can name them with the real class constants.
237
- # Float is the exception: JSON has one number type, so a whole Float
238
- # arrives as `1` from every encoder that drops the trailing zero
239
- # (graphql-js and Go both do), and Coerce.float widens that without
240
- # accepting the garbage `.to_f` would silently turn into 0.0. Date
241
- # deserializes via ISO-8601 (it *does* define .parse, but we want iso8601
242
- # specifically, so it's explicit). The rest carry no cast and coerce
243
- # input by their Ruby type — see coerce_input.
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, cast: :iso8601, serialize: :iso8601, requires: "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
@@ -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. BigDecimal is the one people
1296
- # reach for (it defines neither .parse nor .load, so inference finds no
1297
- # codec and leaves the value untouched).
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
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.1"
3
3
  end
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("Money", Money, requires: "bigdecimal")
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
- # type: and cast:/serialize: are inferred from it .parse/#to_s, or
628
- # .load/.dump by probing the deserialize side (see ScalarType::CODECS).
629
- # Override with a Symbol method name (safestno string to misspell), a
630
- # Proc(expr) => code string, or :itself to force pass-through. requires:
631
- # (a String or Array) names files the generated code needs validated,
632
- # and actually required to confirm it resolves when type: is a real class.
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. Built-in scalars are
636
- # pre-registered the same way, so this also overrides them.
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper