field_struct 0.15.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 +7 -0
- data/AGENTS.md +58 -0
- data/CHANGELOG.md +653 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +645 -0
- data/UPGRADING.md +743 -0
- data/USAGE.md +820 -0
- data/docs/getting_started.md +337 -0
- data/lib/field_struct/base.rb +1316 -0
- data/lib/field_struct/error.rb +48 -0
- data/lib/field_struct/errors.rb +103 -0
- data/lib/field_struct/field.rb +179 -0
- data/lib/field_struct/metadata.rb +331 -0
- data/lib/field_struct/model_name.rb +48 -0
- data/lib/field_struct/rbs.rb +299 -0
- data/lib/field_struct/registry.rb +85 -0
- data/lib/field_struct/scaffold.rb +331 -0
- data/lib/field_struct/types/array.rb +156 -0
- data/lib/field_struct/types/base.rb +162 -0
- data/lib/field_struct/types/big_decimal.rb +78 -0
- data/lib/field_struct/types/binary.rb +38 -0
- data/lib/field_struct/types/boolean.rb +126 -0
- data/lib/field_struct/types/date.rb +158 -0
- data/lib/field_struct/types/datetime.rb +163 -0
- data/lib/field_struct/types/email.rb +49 -0
- data/lib/field_struct/types/float.rb +80 -0
- data/lib/field_struct/types/immutable_string.rb +18 -0
- data/lib/field_struct/types/integer.rb +133 -0
- data/lib/field_struct/types/nested.rb +82 -0
- data/lib/field_struct/types/preset_resolver.rb +37 -0
- data/lib/field_struct/types/rfc3339_format.rb +197 -0
- data/lib/field_struct/types/string.rb +73 -0
- data/lib/field_struct/types/symbol.rb +38 -0
- data/lib/field_struct/types/temporal_parser.rb +93 -0
- data/lib/field_struct/types/time.rb +168 -0
- data/lib/field_struct/types/time_format_resolver.rb +54 -0
- data/lib/field_struct/types/union.rb +96 -0
- data/lib/field_struct/types/url.rb +46 -0
- data/lib/field_struct/types/uuid.rb +46 -0
- data/lib/field_struct/types/value.rb +24 -0
- data/lib/field_struct/types/zone_option.rb +267 -0
- data/lib/field_struct/version.rb +7 -0
- data/lib/field_struct.rb +159 -0
- data/sig/field_struct.rbs +2845 -0
- data/skills/field-struct/SKILL.md +183 -0
- metadata +139 -0
data/USAGE.md
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
# FieldStruct — usage reference
|
|
2
|
+
|
|
3
|
+
Dense, example-first reference for using the `field_struct` gem. Optimized for an
|
|
4
|
+
agent (or human) to load once and use the whole API correctly. For narrative
|
|
5
|
+
docs see `README.md`; for design rationale see `docs/origin/plan.md`.
|
|
6
|
+
|
|
7
|
+
**Mental model:** declare fields on a `FieldStruct::Base` subclass. Each field
|
|
8
|
+
gets a coerced, typed reader/writer and presence/format validation. Construction
|
|
9
|
+
and validity are *separate* — you can build an invalid instance; ask `valid?`.
|
|
10
|
+
|
|
11
|
+
<!-- doctest -->
|
|
12
|
+
```ruby
|
|
13
|
+
class User < FieldStruct::Base
|
|
14
|
+
required :name, :string
|
|
15
|
+
optional :age, :integer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
u = User.new(name: 'Alice', age: '30') # string + symbol keys both work
|
|
19
|
+
u.name # => "Alice"
|
|
20
|
+
u.age # => 30
|
|
21
|
+
u.attributes # => { name: "Alice", age: 30 }
|
|
22
|
+
u.valid? # => true
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Types
|
|
28
|
+
|
|
29
|
+
Register name in the first column; use it as the second arg to `field`/`required`/
|
|
30
|
+
`optional`. "Coerces from" is what the setter accepts (`nil` always passes through
|
|
31
|
+
as `nil`). "Missing" is what counts as absent for presence checks.
|
|
32
|
+
|
|
33
|
+
| Name | Ruby type | Coerces from | Type options | Missing when |
|
|
34
|
+
|------|-----------|--------------|--------------|--------------|
|
|
35
|
+
| `:string` | `String` | `value.to_s` (`strict: true`: scalars and `to_str` only) | `format:`, `enum:`, `strict:` | nil / empty / whitespace-only |
|
|
36
|
+
| `:immutable_string` | `String` (frozen) | `to_s`, then frozen dup | `format:`, `enum:`, `strict:` | nil / empty / whitespace |
|
|
37
|
+
| `:integer` | `Integer` | strings in base 10 (`"010"` → 10; `"0x1A"`/`"3.14"` raise), or `base:`; floats truncate (`strict: true`: refused unless whole) | `in:`, `base:`, `strict:` | nil |
|
|
38
|
+
| `:float` | `Float` | `Kernel.Float` (strict; base-10 strings only — `"0x1A"` raises; NaN/±Infinity refused) | `round:`, `in:` | nil |
|
|
39
|
+
| `:big_decimal` (alias `:decimal`) | `BigDecimal` | BigDecimal/Integer/Float/numeric string (NaN/±Infinity refused) | `round:`, `in:` | nil |
|
|
40
|
+
| `:boolean` | `true`/`false` | `true`/`false`, `1`/`0`, truthy/falsy strings | `values:` | nil |
|
|
41
|
+
| `:date` | `Date` | `Date`, `DateTime`/`Time` (narrowed), String | `format:`, `in:` | nil |
|
|
42
|
+
| `:time` | `Time` | `Time`, `Date`/`DateTime` (from components), String | `format:`, `in:` | nil |
|
|
43
|
+
| `:datetime` | `DateTime` | `DateTime`, `Date`/`Time` (from components), String | `format:`, `in:` | nil |
|
|
44
|
+
| `:symbol` | `Symbol` | `Symbol`, `String#to_sym` (else `TypeError`) | `enum:` | nil |
|
|
45
|
+
| `:uuid` | `String` | `to_s` | `format:` (`:any_version`/`:v4`/`:v7`), `enum:`, `strict:` | nil / empty / whitespace |
|
|
46
|
+
| `:url` | `String` | `to_s` | `format:` (`:http`/`:https_only`/`:any_scheme`), `enum:`, `strict:` | nil / empty / whitespace |
|
|
47
|
+
| `:email` | `String` | `to_s` | `format:` (`:permissive`/`:default`/`:strict`), `enum:`, `strict:` | nil / empty / whitespace |
|
|
48
|
+
| `:binary` | `String` (ASCII-8BIT) | `to_s`, forced to binary encoding | `strict:` | nil / empty (**whitespace bytes are kept**) |
|
|
49
|
+
| `:value` | `Object` (untyped) | pass-through, no coercion | — | nil |
|
|
50
|
+
| `:array` | `Array` | each element coerced through `of:` (with `element:` options) | `of:` (**required**), `element:` | nil / empty |
|
|
51
|
+
| `:union` | first matching member | tries each member type | `of:` (**Array, ≥2**) | nil |
|
|
52
|
+
| nested (a `FieldStruct::Base` subclass) | that class | Hash → instance, or instance | — | nil |
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
required :id, :uuid, format: :v4
|
|
56
|
+
required :website, :url, format: :https_only
|
|
57
|
+
optional :email, :email # default regex
|
|
58
|
+
required :status, :symbol, enum: %i[active archived]
|
|
59
|
+
required :score, :integer, in: 0..100
|
|
60
|
+
required :price, :big_decimal, round: 2
|
|
61
|
+
required :code, :integer, base: 16 # "1A" → 26
|
|
62
|
+
optional :tags, :array, of: :string
|
|
63
|
+
optional :ids, :array, of: :integer, element: { strict: true }
|
|
64
|
+
required :ref, :union, of: %i[string integer]
|
|
65
|
+
optional :address, Address # nested FieldStruct
|
|
66
|
+
optional :lines, :array, of: LineItem # array of nested
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Each type describes its own options at runtime** — you don't have to trust this
|
|
70
|
+
table. `FieldStruct.types.lookup(:date).option_schema` returns
|
|
71
|
+
`{ format: {type:, required:, presets:}, in: {...} }`. The DSL validates these
|
|
72
|
+
*native* options at class load: a wrong-shaped value (`round: '2'`) or a
|
|
73
|
+
misapplied option (`enum:` on `:integer`) raises with the expected shape / the
|
|
74
|
+
types it applies to. An option the type doesn't recognize is **not** an error —
|
|
75
|
+
it's stored verbatim on the `Field`, so downstream tooling (e.g. an Avro exporter)
|
|
76
|
+
can stash and read its own options.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Field options (any type)
|
|
81
|
+
|
|
82
|
+
| Option | Meaning |
|
|
83
|
+
|--------|---------|
|
|
84
|
+
| `required: true` | presence-checked (`required :x` is sugar; `optional :x` / bare `field :x` are not) |
|
|
85
|
+
| `default:` | literal, **or** a callable (`-> { Time.now }`, a Proc/Method) invoked once per instance |
|
|
86
|
+
| `coercion_policy:` | `:keep_raw` (default) \| `:replace` \| `:raise` — per-field override of the class setting |
|
|
87
|
+
| `description:` / `desc:` | human text; introspection only — **never** in `attributes`/`as_json`/pattern match |
|
|
88
|
+
| `rbs_type:` | String of RBS that `FieldStruct::RBS` emits for this field instead of the derived type (`'::ActiveRecord::Relation'`); signatures only, no runtime effect |
|
|
89
|
+
| `sensitive:` | `true` renders `[FILTERED]` for the value in `inspect` / `pp`; data paths unchanged — see [Log safety](#log-safety-error-messages-and-sensitive-fields) |
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class Account < FieldStruct::Base
|
|
93
|
+
required :id, :uuid, default: -> { SecureRandom.uuid }, desc: 'primary key'
|
|
94
|
+
optional :balance, :big_decimal, default: 0, coercion_policy: :raise
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Type-specific options: `format:` (string-shaped + date/time/datetime — a regex or a
|
|
99
|
+
strftime string or a preset Symbol), `round:` (float/decimal — Integer places),
|
|
100
|
+
`base:` (integer — radix 2..36 for string input, default 10), `strict:` (string-shaped
|
|
101
|
+
and integer — Boolean; see [Strict coercion](#strict-coercion-strict-true)),
|
|
102
|
+
`values:` (boolean — a `{truthy:, falsy:}` Hash or a preset Symbol), `of:` (array
|
|
103
|
+
element type / union member array), `element:` (array — a Hash of the element
|
|
104
|
+
type's own options; see [Element options](#element-options-element)), `enum:` (string-like — Array of allowed values),
|
|
105
|
+
`in:` (numeric/temporal — Array or Range), `zone:` (time/datetime — `"UTC"`,
|
|
106
|
+
`"±HH:MM"` or an IANA name like `"America/Denver"` for zone-less input; see [Zones](#zones-zone)).
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
required :joined, :date, format: '%m/%d/%Y' # custom strftime
|
|
110
|
+
required :paid, :boolean, values: :english_yes_no
|
|
111
|
+
required :flag, :boolean, values: { truthy: %w[on], falsy: %w[off] }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Boolean presets: `:english_yes_no`/`:english` (`true yes y on 1` / `false no n off 0`),
|
|
115
|
+
`:numeric` (`1`/`0`). Date presets: `:iso8601 :us :eu`. Time/DateTime presets:
|
|
116
|
+
`:iso8601 :rfc2822 :db`.
|
|
117
|
+
|
|
118
|
+
### The numeric contract
|
|
119
|
+
|
|
120
|
+
- **Integer strings are base 10.** `"010"` is 10 and `"007"` is 7 — not octal —
|
|
121
|
+
and `"0x1A"` / `"0b11"` are refused rather than read as hex/binary. Surrounding
|
|
122
|
+
whitespace and digit underscores are fine (`" 42 "` → 42, `"1_000"` → 1000).
|
|
123
|
+
Declare `base:` (2..36, checked at class load) for another radix:
|
|
124
|
+
`field :code, :integer, base: 16` reads `"1A"` as 26. Integers pass through and
|
|
125
|
+
Floats still truncate; `base:` only affects strings.
|
|
126
|
+
- **Float strings are base 10 too.** `"0x1A"` (which `Kernel.Float` reads as 26.0)
|
|
127
|
+
is refused, like `"0b101"` and `"0o17"`. Every other `Kernel.Float` form still
|
|
128
|
+
reads: `"1_000.5"`, `" 2.5 "`, `"1e3"`, `".5"`, `"017"` (→ 17.0).
|
|
129
|
+
- **NaN and ±Infinity are never valid.** `:integer`, `:float` and `:decimal` refuse
|
|
130
|
+
them — as a Float, a BigDecimal, a String (`"NaN"`, `"Infinity"`) or JSON `1e400` —
|
|
131
|
+
as an ordinary coercion failure under the field's `coercion_policy`. A `:union`
|
|
132
|
+
refuses a non-finite Float/BigDecimal outright rather than letting a `:string`
|
|
133
|
+
member store `"NaN"`. Like every FieldStruct message, these name the input class,
|
|
134
|
+
never the value — see [Log safety](#log-safety-error-messages-and-sensitive-fields).
|
|
135
|
+
- **`round:` keeps the field's type and rounds half up** (ties away from zero).
|
|
136
|
+
`round: 0` returns a `Float` / `BigDecimal`, not an Integer, and `:decimal`
|
|
137
|
+
ignores the process-global `BigDecimal.mode` — a host that sets banker's rounding
|
|
138
|
+
still gets `12.345` → `12.35` at `round: 2`.
|
|
139
|
+
|
|
140
|
+
### Strict coercion (`strict: true`)
|
|
141
|
+
|
|
142
|
+
The defaults are lenient on purpose — they match what ActiveModel does, so a Rails
|
|
143
|
+
client sees the same values. A field that must not accept "close enough" (amounts,
|
|
144
|
+
account numbers) can opt in per field with `strict: true`. Strict only *narrows*:
|
|
145
|
+
anything it accepts becomes exactly what the default would make of it.
|
|
146
|
+
|
|
147
|
+
| Input | `:string` | `:string, strict: true` | `:integer` | `:integer, strict: true` |
|
|
148
|
+
|-------|-----------|-------------------------|------------|--------------------------|
|
|
149
|
+
| `"abc"` / `"42"` | `"abc"` / `"42"` | same | — / 42 | — / 42 |
|
|
150
|
+
| `:abc` | `"abc"` | `"abc"` | `TypeError` | `TypeError` |
|
|
151
|
+
| `42` | `"42"` | `"42"` | 42 | 42 |
|
|
152
|
+
| `12.0` / `BigDecimal("12.0")` | `"12.0"` / `"0.12e2"` | `TypeError` | 12 / 12 | 12 / 12 |
|
|
153
|
+
| `12.7` / `Rational(25, 2)` | `"12.7"` / `"25/2"` | `TypeError` | 12 (truncated) | `ArgumentError` |
|
|
154
|
+
| `"12.7"` / `"12.0"` / `"1e3"` / `"0x1A"` | as-is | as-is | `ArgumentError` | `ArgumentError` |
|
|
155
|
+
| object with `to_str` | `to_s` | `to_s` | read as its `to_str` string | read as its `to_str` string |
|
|
156
|
+
| `{a: 1}` / `[1]` / `true` / `Time` / `Object.new` | their `to_s` | `TypeError` | `TypeError` (a `Time` reads as its epoch) | `TypeError` (a `Time` → `ArgumentError`) |
|
|
157
|
+
| `Float::NAN` / `Infinity` | `"NaN"` / `"Infinity"` | `TypeError` | `ArgumentError` | `ArgumentError` |
|
|
158
|
+
|
|
159
|
+
- **`:string` strict** accepts a String, a Symbol, an Integer and anything
|
|
160
|
+
responding to `to_str`. An Integer counts because a JSON payload often carries an
|
|
161
|
+
identifier as a number (`"account": 12345`) and its text is unambiguous. A Float,
|
|
162
|
+
BigDecimal or Rational is refused: its `to_s` (`"1.0e+20"`, `"0.12e2"`, `"1/2"`)
|
|
163
|
+
is not the text the sender meant. Booleans, Hashes,
|
|
164
|
+
Arrays, Complex and other objects are refused — their `to_s` is a rendering, not
|
|
165
|
+
a value. It applies to the string subtypes too (`:immutable_string`, `:uuid`,
|
|
166
|
+
`:url`, `:email`, `:binary`).
|
|
167
|
+
- **`:integer` strict** accepts a value only when it *is* a whole number: an
|
|
168
|
+
Integer, or a numeric equal to its integer (`12.0`, `BigDecimal("12")`,
|
|
169
|
+
`Rational(24, 2)`). Strings follow the default rules exactly — integer digits in
|
|
170
|
+
base 10 or `base:` — so a float-shaped string like `"12.0"` or `"1e3"` is refused
|
|
171
|
+
in both modes.
|
|
172
|
+
- Refusals are ordinary coercion failures under the field's `coercion_policy`, and
|
|
173
|
+
their messages name the input class, never the value.
|
|
174
|
+
- `strict:` must be `true`/`false` (checked at declaration); on any other type it
|
|
175
|
+
raises `strict: option does not apply to Float fields. It applies to: String, ImmutableString, Integer, …`
|
|
176
|
+
|
|
177
|
+
<!-- doctest -->
|
|
178
|
+
```ruby
|
|
179
|
+
class Charge < FieldStruct::Base
|
|
180
|
+
required :reference, :string, strict: true
|
|
181
|
+
required :cents, :integer, strict: true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
Charge.new(reference: :ord_1, cents: 1250.0).cents # => 1250
|
|
185
|
+
Charge.new(reference: { id: 1 }, cents: 1).valid? # => false
|
|
186
|
+
Charge.new(reference: 'ord_1', cents: 12.5).valid? # => false
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### The temporal contract
|
|
190
|
+
|
|
191
|
+
Three rules apply to `:date` / `:time` / `:datetime`, and they exist because the
|
|
192
|
+
stdlib parsers answer questions they were not asked.
|
|
193
|
+
|
|
194
|
+
- **A declared `format:` anchors.** `'2026-07-031'` under `'%Y-%m-%d'` is refused
|
|
195
|
+
rather than read as the 3rd with the trailing character dropped. "Anchor" means
|
|
196
|
+
trailing-junk rejection and nothing more — a hand-written format stays
|
|
197
|
+
strptime-lenient about widths and sign, because `%m` accepting one or two digits
|
|
198
|
+
is strptime's contract.
|
|
199
|
+
- **A day that never existed is refused**, by all three types. `'2026-06-31'` is
|
|
200
|
+
not 1 July.
|
|
201
|
+
- **With no declared format, the string must name a whole day.** `Time.parse('10:30')`
|
|
202
|
+
is *today* at 10:30 and `'July'` is 1 July of this year; a value whose meaning
|
|
203
|
+
depends on when it was parsed is not a value. A *declared* partial format
|
|
204
|
+
(`format: '%H:%M'`) is a deliberate choice and still works.
|
|
205
|
+
|
|
206
|
+
A blank string is a coercion failure, as it already was for `:integer`, `:float`,
|
|
207
|
+
`:big_decimal` and `:boolean`. Only `:string` treats blank as missing.
|
|
208
|
+
|
|
209
|
+
**`format: :iso8601` is RFC 3339**, which is what "ISO-8601" means in an API
|
|
210
|
+
contract and what JSON Schema's `date-time` refers to. FieldStruct parses and
|
|
211
|
+
renders it itself rather than delegating to `Time.iso8601` / `DateTime.iso8601`,
|
|
212
|
+
which disagree with each other (and `Time.iso8601` reads `2026-02-30T10:30:00Z`
|
|
213
|
+
as 2 March). It accepts full-width fields, a `T` separator, whole seconds,
|
|
214
|
+
optional fractional seconds, and a **mandatory** offset (`Z` or `±hh:mm`); it
|
|
215
|
+
refuses `2026-7-3`, `+2026-07-03`, `20261-07-03`, `20260703`, week and ordinal
|
|
216
|
+
dates, a missing offset, hour 24 and second 60. Output is `Z` for UTC and
|
|
217
|
+
`+HH:MM` otherwise.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
required :on, :date, format: :iso8601 # 2026-07-03 exactly, and a real day
|
|
221
|
+
required :at, :datetime, format: :iso8601 # 2026-07-03T10:30:00Z, offset required
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
That strictness is the point: it is what lets you declare the field instead of
|
|
225
|
+
hand-rolling `format: /\A\d{4}-\d{2}-\d{2}\z/` plus a validator. The other
|
|
226
|
+
presets (`:us`, `:eu`, `:db`, `:rfc2822`) are display and interchange formats, so
|
|
227
|
+
they stay strftime-based — with anchoring and civil-date validity still applied.
|
|
228
|
+
|
|
229
|
+
### Zones (`zone:`)
|
|
230
|
+
|
|
231
|
+
By default a zone-less string follows the stdlib semantics of the type you
|
|
232
|
+
declared: `:time` uses `Time.parse` and lands in the *process* zone, `:datetime`
|
|
233
|
+
uses `DateTime.parse` and lands in **UTC**. So `"2026-10-22 10:00"` in a `:time`
|
|
234
|
+
field is a different instant on a server in Denver than on one in Tokyo.
|
|
235
|
+
|
|
236
|
+
Declare `zone:` on a `:time` or `:datetime` field to name the zone a zone-less
|
|
237
|
+
value means. The same input then gives the same answer on any server. `zone:`
|
|
238
|
+
only fills in a **missing** offset — it never changes one the input carries.
|
|
239
|
+
|
|
240
|
+
`zone:` takes `"UTC"`, a fixed offset (`"-06:00"`, the same all year) or an IANA
|
|
241
|
+
name (`"America/Denver"`, which follows daylight saving: -06:00 in July, -07:00 in
|
|
242
|
+
January). With a name, a wall-clock time gets the offset in force **at that date**.
|
|
243
|
+
|
|
244
|
+
| Input | no `zone:` | `zone: "-06:00"` | `zone: "America/Denver"` |
|
|
245
|
+
|-------|------------|------------------|--------------------------|
|
|
246
|
+
| `"2026-10-22 10:00"` (no offset) | `:time` process zone; `:datetime` UTC | 10:00 at -06:00 | 10:00 at Denver's offset that day (`"2026-01-15 10:00"` → -07:00) |
|
|
247
|
+
| a `format:` without `%z` (`:db`, `'%H:%M'`) | as above | 10:00 at -06:00; a date the format omits is *today in -06:00* | the same, at that date's offset; an omitted date is *today in Denver* |
|
|
248
|
+
| `"2026-10-22T10:00:00+02:00"`, `"… Z"`, `"… EST"` | its own offset | its own offset (unchanged) | its own offset (unchanged) |
|
|
249
|
+
| `"2026-10-22 10:00 XYZ"`, `"… America/Denver"` (a zone in the *string*) | `:time` process zone; `:datetime` UTC | refused — `zone:` won't override a stated zone | refused, likewise |
|
|
250
|
+
| `Time` / `DateTime` / TimeWithZone | instant and offset kept | instant and offset kept (not converted) | instant and offset kept (not converted) |
|
|
251
|
+
| `Date` | midnight: `:time` process zone, `:datetime` UTC | midnight at -06:00 | midnight at that day's offset |
|
|
252
|
+
| an epoch string under `format: '%s'` | that instant, in the process zone (`:time`) or UTC (`:datetime`) | that instant, at -06:00 | that instant, at Denver's offset at that instant |
|
|
253
|
+
| Integer (epoch seconds) | refused | refused | refused |
|
|
254
|
+
|
|
255
|
+
- **Accepted values:** `"UTC"` (the `:time` value is then `utc?`), fixed offsets
|
|
256
|
+
`"+HH:MM"` / `"-HH:MM"` from `-23:59` to `+23:59` (colon required), and IANA
|
|
257
|
+
`Area/Location` names in the zone database (`"America/Denver"`, `"Etc/UTC"`,
|
|
258
|
+
`"America/Argentina/Buenos_Aires"`). Anything else raises `ArgumentError` at
|
|
259
|
+
declaration, naming the option: `"utc"`, `"Z"`, `"-0600"`, an unknown name, and
|
|
260
|
+
abbreviations or legacy aliases with no `/` — `"MST"` is in the zone database,
|
|
261
|
+
but as a fixed -07:00, not Mountain time with DST, so it is refused rather than
|
|
262
|
+
quietly meaning something else.
|
|
263
|
+
- **Rails zone names are refused** (`"Mountain Time (US & Canada)"`). They are
|
|
264
|
+
ActiveSupport aliases that exist only when ActiveSupport is loaded, so accepting
|
|
265
|
+
them would make the same declaration valid in one process and invalid in another.
|
|
266
|
+
Use the IANA name they map to (`ActiveSupport::TimeZone::MAPPING[name]`).
|
|
267
|
+
- **Daylight-saving edges** (names only — a fixed offset has none):
|
|
268
|
+
- A wall-clock time the zone **skips** (`"2026-03-08 02:30"` in Denver, inside the
|
|
269
|
+
spring-forward hour) is refused: `could not be coerced: names a local time that
|
|
270
|
+
does not exist in America/Denver (skipped by a daylight-saving change)`. It names
|
|
271
|
+
no instant, like the 30th of February. ActiveSupport moves it an hour forward
|
|
272
|
+
(03:30); FieldStruct does not guess.
|
|
273
|
+
- A wall-clock time the zone **repeats** (`"2026-11-01 01:30"`, which happens at
|
|
274
|
+
-06:00 and again at -07:00) is read at the **daylight-time** offset, the earlier
|
|
275
|
+
instant — what ActiveSupport's `TimeWithZone` picks, so a Rails app sees the
|
|
276
|
+
same answer.
|
|
277
|
+
- A `Date` whose midnight the zone skips becomes the **first moment** of that day
|
|
278
|
+
(01:00 -03:00 in `America/Santiago` on 6 September 2026), as `Date#in_time_zone` does.
|
|
279
|
+
- **What you get back** is a plain `Time` (or `DateTime`) with that offset — never a
|
|
280
|
+
`TZInfo::TimeWithOffset`, and it does not remember the zone: `Time#zone` is `nil`
|
|
281
|
+
and adding a day keeps the offset rather than following DST. Re-assign to re-read.
|
|
282
|
+
- **Dependency:** the zone database is [tzinfo](https://github.com/tzinfo/tzinfo)'s
|
|
283
|
+
(a runtime dependency since 0.14), required only when a field declares a name — a
|
|
284
|
+
`"UTC"` or fixed-offset field never loads it. tzinfo reads the OS zoneinfo
|
|
285
|
+
(`/usr/share/zoneinfo`); on a system without it (Windows, slim containers) a
|
|
286
|
+
declaration raises `ArgumentError` saying to add the `tzinfo-data` gem.
|
|
287
|
+
- `zone:` has no effect with `format: :iso8601`, which requires an offset anyway.
|
|
288
|
+
It does not apply to `:date` (a date has no time of day to place).
|
|
289
|
+
|
|
290
|
+
<!-- doctest -->
|
|
291
|
+
```ruby
|
|
292
|
+
class Shift < FieldStruct::Base
|
|
293
|
+
required :starts, :time, zone: '-06:00'
|
|
294
|
+
optional :logged, :datetime, zone: 'UTC'
|
|
295
|
+
optional :ends, :time, zone: 'UTC', format: :db
|
|
296
|
+
optional :local, :time, zone: 'America/Denver'
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
shift = Shift.new(starts: '2026-10-22 10:00', ends: '2026-10-22 18:30:00')
|
|
300
|
+
shift.starts.utc_offset # => -21600
|
|
301
|
+
shift.starts.hour # => 10
|
|
302
|
+
shift.ends.utc? # => true
|
|
303
|
+
Shift.new(starts: '2026-10-22T10:00:00+02:00').starts.utc_offset # => 7200
|
|
304
|
+
Shift.new(starts: '2026-10-22 10:00', logged: '2026-10-22 10:00').logged.offset # => 0
|
|
305
|
+
|
|
306
|
+
Shift.new(starts: '2026-07-01 10:00', local: '2026-07-01 10:00').local.utc_offset # => -21600
|
|
307
|
+
Shift.new(starts: '2026-01-15 10:00', local: '2026-01-15 10:00').local.utc_offset # => -25200
|
|
308
|
+
Shift.new(starts: '2026-11-01 10:00', local: '2026-11-01 01:30').local.utc_offset # => -21600
|
|
309
|
+
gap = Shift.new(starts: '2026-03-08 10:00', local: '2026-03-08 02:30')
|
|
310
|
+
gap.errors[:local].first.end_with?('does not exist in America/Denver (skipped by a daylight-saving change)') # => true
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Class macros
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
class Doc < FieldStruct::Base
|
|
319
|
+
immutable! # setters raise ImmutableError after initialize
|
|
320
|
+
frozen! # Ruby-freeze the instance at end of initialize (stackable with immutable!)
|
|
321
|
+
coercion_policy :replace # class-wide coercion-failure policy; inherited by subclasses
|
|
322
|
+
unknown_attributes :raise # reject unknown input keys (default :ignore = drop them)
|
|
323
|
+
|
|
324
|
+
required :title, :string
|
|
325
|
+
required :slug, :string
|
|
326
|
+
|
|
327
|
+
serialize :json, title: 'docTitle' # map internal field -> external JSON key (:json wired in)
|
|
328
|
+
|
|
329
|
+
validate :slug_matches_title # cross-field validator (method symbol)
|
|
330
|
+
validate { |rec| rec.errors.add(:base, 'empty') if rec.title.to_s.empty? } # or block
|
|
331
|
+
|
|
332
|
+
def slug_matches_title
|
|
333
|
+
errors.add(:base, 'slug/title mismatch') unless slug == title.downcase
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- `coercion_policy` / `unknown_attributes` are read with no arg, set with one.
|
|
339
|
+
- **Inheritance:** all four macros inherit to subclasses; a child overrides by re-declaring, and the override never mutates the parent. `coercion_policy`/`unknown_attributes` override to any value; `immutable!`/`frozen!` are **one-way** — a child can add them but can't un-set an inherited one (no `mutable!`/`unfrozen!`).
|
|
340
|
+
- `validate` runs at `valid?` (and at construct if any exist); convention is `errors.add(:base, ...)`; `errors[:base]` is cleared each `valid?` run.
|
|
341
|
+
|
|
342
|
+
### Payment and API boundaries: `unknown_attributes :raise`
|
|
343
|
+
|
|
344
|
+
At a trust boundary (a payment request, a webhook, a partner API body) declare
|
|
345
|
+
`unknown_attributes :raise`. The default, `:ignore`, drops keys it doesn't know
|
|
346
|
+
without telling you. So a misspelled key (`"ammount"`) or a renamed field upstream
|
|
347
|
+
leaves a field unset, and a field you haven't modelled yet (`"cvv"`) goes away without
|
|
348
|
+
a trace. With `:raise`, `new` / `from_json` / `assign_attributes` / `with` fail with an
|
|
349
|
+
`UnknownAttributeError` whose `keys` name the extras (names only, never values).
|
|
350
|
+
|
|
351
|
+
- **Declare it on every struct that reads input**, nested ones too. It is inherited
|
|
352
|
+
by *subclasses*, not by structs a field nests: a nested class without it still
|
|
353
|
+
drops its own unknown keys. The nested error propagates out of the parent's
|
|
354
|
+
`new` rather than going through the parent's `coercion_policy`.
|
|
355
|
+
- **`from_json` reverse-maps `serialize :json` names first**, so external names
|
|
356
|
+
(`"amountDecimal"`) are known keys there. Passing them to `new` raises, because
|
|
357
|
+
`new` takes canonical names.
|
|
358
|
+
- Keep `:ignore` where extra keys are expected and harmless, e.g. a struct reading
|
|
359
|
+
a few fields out of a large third-party payload.
|
|
360
|
+
|
|
361
|
+
<!-- doctest -->
|
|
362
|
+
```ruby
|
|
363
|
+
class Card < FieldStruct::Base
|
|
364
|
+
unknown_attributes :raise
|
|
365
|
+
required :last4, :string
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
class Charge < FieldStruct::Base
|
|
369
|
+
unknown_attributes :raise
|
|
370
|
+
required :amount, :decimal
|
|
371
|
+
required :card, Card
|
|
372
|
+
serialize :json, amount: 'amountDecimal'
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
Charge.from_json('{"amountDecimal":"9.99","card":{"last4":"4242"}}').valid? # => true
|
|
376
|
+
|
|
377
|
+
typo = begin
|
|
378
|
+
Charge.from_json('{"ammount":"9.99","card":{"last4":"4242"}}')
|
|
379
|
+
rescue FieldStruct::UnknownAttributeError => e
|
|
380
|
+
e
|
|
381
|
+
end
|
|
382
|
+
typo.keys # => [:ammount]
|
|
383
|
+
typo.message # "unknown attribute(s) for Charge: :ammount"
|
|
384
|
+
|
|
385
|
+
nested = begin
|
|
386
|
+
Charge.from_json('{"amountDecimal":"9.99","card":{"last4":"4242","cvv":"123"}}')
|
|
387
|
+
rescue FieldStruct::UnknownAttributeError => e
|
|
388
|
+
e
|
|
389
|
+
end
|
|
390
|
+
nested.keys # => [:cvv]
|
|
391
|
+
nested.message # "unknown attribute(s) for Card: :cvv"
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Instance surface
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
u = User.new(name: 'Alice', age: '30') # Hash arg; Symbol or String keys
|
|
400
|
+
u.name; u.age = 31 # typed reader; permissive setter (coerces)
|
|
401
|
+
u.attributes # => { name: "Alice", age: 31 } (alias: to_h)
|
|
402
|
+
u.attribute_names # => [:name, :age]
|
|
403
|
+
u.valid? # runs cross-field validators, then errors.empty?
|
|
404
|
+
u.invalid?
|
|
405
|
+
u.errors # FieldStruct::Errors
|
|
406
|
+
u.inspect # #<User name: "Alice" age: 31>; an invalid instance appends
|
|
407
|
+
# `errors: {name: ["is required"]}` so it never looks fine
|
|
408
|
+
u == other # value equality (same class + same attributes; errors ignored)
|
|
409
|
+
u.as_json # JSON-ready Hash with :json mapping applied
|
|
410
|
+
u.to_json # String (via Oj)
|
|
411
|
+
User.from_json(str) # parse + reverse-map :json keys -> instance
|
|
412
|
+
u.assign_attributes(age: 40) # bulk update through the setter pipeline
|
|
413
|
+
u.with(age: 41) # NEW instance with age changed; u untouched
|
|
414
|
+
case u; in { name: 'Alice', age: } then age; end # pattern matching
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Copying with changes (`with`)
|
|
418
|
+
|
|
419
|
+
`with(**changes)` returns a new instance of the receiver's class with the given
|
|
420
|
+
attributes changed, like `Data#with`. The receiver is never modified, so it is the
|
|
421
|
+
way to "update" an `immutable!` or `frozen!` instance.
|
|
422
|
+
|
|
423
|
+
- **Changed fields go through the setter pipeline**: coercion, the field's
|
|
424
|
+
`coercion_policy` (under `:raise` the `CoercionError` propagates and no copy is
|
|
425
|
+
returned), and that field's errors rewritten. Cross-field `validate` blocks rerun.
|
|
426
|
+
- **Unchanged fields are carried over as they are**, errors included, exactly as
|
|
427
|
+
`dup` copies them. Callable defaults are not called again.
|
|
428
|
+
- **Shallow**, like `dup`: an unchanged nested struct or array is the *same object*
|
|
429
|
+
in both instances. Mutating one through the other is visible in both.
|
|
430
|
+
- **Unknown keys** follow `unknown_attributes` (dropped by default, `UnknownAttributeError`
|
|
431
|
+
under `:raise`). String keys work too: `with('age' => 41)`.
|
|
432
|
+
- **Keywords only**: `with({age: 41})` raises `ArgumentError`; splat a Hash with
|
|
433
|
+
`with(**hash)`. `with` with no changes returns an equal copy, not `self`.
|
|
434
|
+
- The copy is immutable when the class is, and frozen when the receiver is
|
|
435
|
+
frozen. `with` (and `dup`, which it relies on) are reserved field names.
|
|
436
|
+
|
|
437
|
+
<!-- doctest -->
|
|
438
|
+
```ruby
|
|
439
|
+
class Point < FieldStruct::Base
|
|
440
|
+
immutable!
|
|
441
|
+
required :x, :integer
|
|
442
|
+
required :y, :integer
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
origin = Point.new(x: 0, y: 0)
|
|
446
|
+
moved = origin.with(x: '5')
|
|
447
|
+
moved.x # => 5
|
|
448
|
+
origin.x # => 0
|
|
449
|
+
moved.class # => Point
|
|
450
|
+
origin.with(y: nil).valid? # => false
|
|
451
|
+
origin.valid? # => true
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`Errors` API: `errors[:field] # => Array<String>`, `errors.add(:field, 'msg')`,
|
|
455
|
+
`errors.clear(:field)`, `errors.empty?`, `errors.to_h` (alias `messages`),
|
|
456
|
+
`errors.full_messages` (each message as a sentence — humanized field name
|
|
457
|
+
prepended, e.g. `first_name`/`is required` → `"First name is required"`; `:base`
|
|
458
|
+
messages pass through unprefixed).
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Validation semantics (important)
|
|
463
|
+
|
|
464
|
+
- **Per-field, on every assignment.** The setter owns its field's errors — it clears
|
|
465
|
+
and rewrites `errors[name]` each time you assign.
|
|
466
|
+
- **Required + missing** → `errors.add(name, 'is required')`.
|
|
467
|
+
- **Present but invalid** → `errors.add(name, 'is invalid')`. "Invalid" = `format:`
|
|
468
|
+
regex mismatch, `enum:`/`in:` violation, an invalid nested struct, or an array with
|
|
469
|
+
an invalid element. An array element breaking its `element:` `format:`/`enum:`/`in:`
|
|
470
|
+
is reported by index: `'element at index 2 is invalid'`. So **"required" means present *and* valid**.
|
|
471
|
+
- **Validity is separate from construction.** A field can hold `nil`/raw even when
|
|
472
|
+
required — see coercion policy below. Always gate on `valid?`, don't assume a
|
|
473
|
+
required reader is non-nil.
|
|
474
|
+
|
|
475
|
+
### Validity is computed on assignment (shallow `valid?` staleness)
|
|
476
|
+
|
|
477
|
+
A field's errors are written when *that field* is assigned, and `valid?` only reads
|
|
478
|
+
them (it reruns cross-field `validate` blocks, nothing else). A parent records an
|
|
479
|
+
invalid nested struct or array element when the nested value is assigned. **It
|
|
480
|
+
doesn't watch it afterwards.** Mutating a nested struct, or an array element, in
|
|
481
|
+
place leaves the parent's errors as they were, in both directions:
|
|
482
|
+
|
|
483
|
+
- the parent stays `valid?` after a nested field is broken in place, and
|
|
484
|
+
- stays invalid after a nested problem is fixed in place.
|
|
485
|
+
|
|
486
|
+
Pushing onto an array in place (`record.tags << 5`) also skips coercion: the raw
|
|
487
|
+
value is stored as-is.
|
|
488
|
+
|
|
489
|
+
**Workaround: assign the field again** (or use `with`/`assign_attributes`, which go
|
|
490
|
+
through the setter). That reruns coercion and validation for that field.
|
|
491
|
+
|
|
492
|
+
<!-- doctest -->
|
|
493
|
+
```ruby
|
|
494
|
+
class Address < FieldStruct::Base
|
|
495
|
+
required :city, :string
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
class Person < FieldStruct::Base
|
|
499
|
+
required :address, Address
|
|
500
|
+
optional :tags, :array, of: :string
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
person = Person.new(address: { city: 'Provo' })
|
|
504
|
+
person.address.city = nil # mutate the nested struct in place
|
|
505
|
+
person.address.valid? # => false
|
|
506
|
+
# Stale: the parent never saw the change.
|
|
507
|
+
person.valid? # => true
|
|
508
|
+
|
|
509
|
+
person.address = person.address # re-assign: the setter re-validates
|
|
510
|
+
person.valid? # => false
|
|
511
|
+
person.errors[:address] # => ['is invalid']
|
|
512
|
+
|
|
513
|
+
person.address.city = 'Provo' # fixed in place...
|
|
514
|
+
# ...and stale the other way.
|
|
515
|
+
person.valid? # => false
|
|
516
|
+
person.with(address: person.address).valid? # => true
|
|
517
|
+
|
|
518
|
+
person.tags = ['a']
|
|
519
|
+
person.tags << 5 # in-place push: no coercion
|
|
520
|
+
person.tags # => ['a', 5]
|
|
521
|
+
person.tags = person.tags # re-assign coerces each element
|
|
522
|
+
person.tags # => ['a', '5']
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
For a nested struct you mutate often, keep it `immutable!` (or `frozen!`) and use
|
|
526
|
+
`with` to change it. Then the only way to change it goes through a setter.
|
|
527
|
+
|
|
528
|
+
### Coercion failure policy
|
|
529
|
+
|
|
530
|
+
When a value can't be coerced into the field's type:
|
|
531
|
+
|
|
532
|
+
| Policy | Stored value | Error added | Notes |
|
|
533
|
+
|--------|--------------|-------------|-------|
|
|
534
|
+
| `:keep_raw` (default) | the raw input | yes (`could not be coerced: …`) | nothing lost; inspect & fix |
|
|
535
|
+
| `:replace` | `nil` | yes | clean slate |
|
|
536
|
+
| `:raise` | — | — | raises `FieldStruct::CoercionError` |
|
|
537
|
+
|
|
538
|
+
Messages name the field, the expected type and the input's *class*, never the value:
|
|
539
|
+
`could not be coerced: Integer fields expect a base-10 integer, got a String that is
|
|
540
|
+
not one`; under `:raise`, `could not coerce String input for cents: …`.
|
|
541
|
+
|
|
542
|
+
### Log safety: error messages and sensitive fields
|
|
543
|
+
|
|
544
|
+
Payments data must not reach a log line, so **no message FieldStruct builds echoes an
|
|
545
|
+
input value** — not an `errors` entry, not a raised error (`CoercionError`, a type's
|
|
546
|
+
`ArgumentError`/`TypeError`, `from_json`'s errors), and not their `cause` chain, which
|
|
547
|
+
error reporters print. Kernel conversion errors (`invalid value for Integer(): "…"`)
|
|
548
|
+
are dropped from the chain; an invalid-JSON error from `from_json` keeps Oj's error
|
|
549
|
+
class and position but not the quoted input. Declared schema (a format, a boolean's
|
|
550
|
+
accepted strings, a field name) is still named. A custom type owns its own messages:
|
|
551
|
+
raise with the value's class, not its `inspect`.
|
|
552
|
+
|
|
553
|
+
`sensitive: true` (any type, `true`/`false` only) masks the value where objects get
|
|
554
|
+
logged — `inspect`, and so `pp`, the `errors:` suffix of an invalid instance, nested
|
|
555
|
+
structs and arrays of them. `nil` still shows, so a missing value stays visible:
|
|
556
|
+
|
|
557
|
+
<!-- doctest -->
|
|
558
|
+
```ruby
|
|
559
|
+
class Card < FieldStruct::Base
|
|
560
|
+
required :number, :string, sensitive: true
|
|
561
|
+
optional :cvv, :string, sensitive: true
|
|
562
|
+
optional :brand, :string
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
card = Card.new(number: '4111111111111111', brand: 'visa')
|
|
566
|
+
card # => #<Card number: [FILTERED], cvv: nil, brand: "visa">
|
|
567
|
+
card.inspect.include?('4111') # => false
|
|
568
|
+
card.number # => "4111111111111111"
|
|
569
|
+
card.to_h # => { number: "4111111111111111", cvv: nil, brand: "visa" }
|
|
570
|
+
Card.metadata[:number].sensitive? # => true
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Data paths are **not** masked — the reader, `attributes`/`to_h`, `as_json`/`to_json`,
|
|
574
|
+
`deconstruct_keys` and equality all return the real value, so `from_json(to_json)`
|
|
575
|
+
still round-trips. Masking there would corrupt the data; filter at the log boundary
|
|
576
|
+
if you log those. `Field#inspect`, `Metadata#to_h` (`sensitive: true`) and `describe`
|
|
577
|
+
show the flag only when it is set.
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Nested, arrays, unions
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
class Address < FieldStruct::Base
|
|
585
|
+
required :city, :string
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
class Person < FieldStruct::Base
|
|
589
|
+
required :name, :string
|
|
590
|
+
optional :address, Address # nested: Hash or Address instance
|
|
591
|
+
optional :aliases, :array, of: :string # array of scalars
|
|
592
|
+
optional :homes, :array, of: Address # array of nested (each coerced + validated)
|
|
593
|
+
required :id, :union, of: %i[string integer] # union: first member that accepts it
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
Person.new(name: 'A', address: { city: 'NYC' }).address # => #<Address city: "NYC">
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Nested validity propagates: a `Person` with an invalid `address` is itself invalid.
|
|
600
|
+
Pattern matching recurses (`in { address: { city: 'NYC' } }`).
|
|
601
|
+
|
|
602
|
+
A refused array element is named by its index (0-based), never its value:
|
|
603
|
+
`errors[:ids] # => ["could not be coerced: element at index 1: Integer fields expect a base-10 integer, got a String that is not one"]`.
|
|
604
|
+
When several are refused, every index is listed and the first one's reason is
|
|
605
|
+
shown — `elements at index 1, 4, 7 (index 1 shown): …` — up to five indexes, then
|
|
606
|
+
a count (`elements at index 0, 1, 2, 3, 4 and 15 more (index 0 shown): …`). The
|
|
607
|
+
same message reaches every `coercion_policy`, `:raise` included.
|
|
608
|
+
|
|
609
|
+
### Element options (`element:`)
|
|
610
|
+
|
|
611
|
+
`element:` takes the options a scalar field of the element type would take, and
|
|
612
|
+
applies them to every element — coercion options (`strict:`, `base:`, `round:`,
|
|
613
|
+
`format:`, `zone:`, `values:`) and validation options (`format:` Regexp, `enum:`,
|
|
614
|
+
`in:`) alike. Array-level options stay on the field (`required:`, `default:`,
|
|
615
|
+
`coercion_policy:`, `sensitive:`, …).
|
|
616
|
+
|
|
617
|
+
<!-- doctest -->
|
|
618
|
+
```ruby
|
|
619
|
+
class Batch < FieldStruct::Base
|
|
620
|
+
optional :ids, :array, of: :integer, element: { strict: true, base: 16 }
|
|
621
|
+
optional :days, :array, of: :date, element: { format: '%d/%m/%Y' }
|
|
622
|
+
optional :codes, :array, of: :string, element: { format: /\A[A-Z]{3}\z/ }
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
Batch.new(ids: %w[ff 10]).ids # => [255, 16]
|
|
626
|
+
Batch.new(ids: [1, 2.5]).errors[:ids] # => ["could not be coerced: element at index 1: Integer fields with strict: true expect a whole number, got a Float that is not one"]
|
|
627
|
+
Batch.new(codes: %w[USD eur]).errors[:codes] # => ["element at index 1 is invalid"]
|
|
628
|
+
Batch.new(codes: %w[usd EUR gbp]).errors[:codes] # => ["elements at index 0, 2 are invalid"]
|
|
629
|
+
Batch.new(ids: [1.5, 2, 'x']).errors[:ids] # => ["could not be coerced: elements at index 0, 2 (index 0 shown): Integer fields with strict: true expect a whole number, got a Float that is not one"]
|
|
630
|
+
Batch.new(days: ['22/10/2026']).as_json[:days] # => ['22/10/2026']
|
|
631
|
+
Batch.metadata.to_h[:ids][:options] # => { of_type: 'Integer', element: { strict: true, base: 16 } }
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
They are checked at declaration against the element type's own schema, with the
|
|
635
|
+
error a scalar field would get, prefixed with the field: `element: { base: '16' }`
|
|
636
|
+
raises `in element: of array field :ids, base: on Integer expects Integer, got String
|
|
637
|
+
("16")`. A key the element type doesn't take raises, listing the ones it does
|
|
638
|
+
(`round: is not an Integer option. Integer accepts: in, base, strict.`) — unlike a
|
|
639
|
+
field, `element:` passes nothing through. A temporal element's `format:` is also
|
|
640
|
+
what `to_json` writes, so `from_json` reads it back. `describe` lists what each
|
|
641
|
+
array's elements accept, by name (`element accepts in, base, strict`); the shapes
|
|
642
|
+
are the element type's own (`FieldStruct::Types::Integer.option_schema`).
|
|
643
|
+
Not supported: `element:` on an `:array` of arrays, and on a nested struct element
|
|
644
|
+
(a struct takes no options).
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## JSON & serialization
|
|
649
|
+
|
|
650
|
+
- `as_json`/`to_json` apply the `:json` `serialize` mapping (forward); `from_json`
|
|
651
|
+
reverse-maps. Round-trips for every base type **except `:value`** (no type info to
|
|
652
|
+
restore).
|
|
653
|
+
- Conversions in `as_json`: `Date`/`Time`/`DateTime` → ISO-8601 (or the field's
|
|
654
|
+
`format:`), `BigDecimal` → plain string, `Symbol` → String, arrays/nested recurse.
|
|
655
|
+
- `from_json` parses JSON numbers with a fraction or exponent as `BigDecimal`, so a
|
|
656
|
+
`:decimal` gets the exact digits sent (`1234567890123.4567`, not `…123.46`).
|
|
657
|
+
`:float` still holds a Float; a `:value` field (and numbers inside its Hashes and
|
|
658
|
+
Arrays) holds the `BigDecimal` — which `to_json` writes back as a string.
|
|
659
|
+
|
|
660
|
+
<!-- doctest -->
|
|
661
|
+
```ruby
|
|
662
|
+
class U < FieldStruct::Base
|
|
663
|
+
required :first, :string
|
|
664
|
+
serialize :json, first: 'firstName'
|
|
665
|
+
end
|
|
666
|
+
U.new(first: 'Al').to_json # => '{"firstName":"Al"}'
|
|
667
|
+
U.from_json('{"firstName":"Al"}') # => #<U first: "Al">
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## Custom types & namespaces
|
|
673
|
+
|
|
674
|
+
A namespace exposes `self.field_types` (a registry parented to the base set); fields
|
|
675
|
+
declared on classes inside that namespace resolve names through it.
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
module Acme
|
|
679
|
+
def self.field_types
|
|
680
|
+
@field_types ||= FieldStruct.new_registry do # parent defaults to FieldStruct.types
|
|
681
|
+
register :money, Acme::Types::Money # add a type
|
|
682
|
+
register :dec, :big_decimal # alias to an existing name
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
class Invoice < FieldStruct::Base
|
|
687
|
+
required :total, :money # resolved via Acme.field_types
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
A custom type is a `FieldStruct::Types::Base` subclass implementing `#coerce` and
|
|
693
|
+
`#ruby_type` (override `#missing?` for a broader notion than nil-only).
|
|
694
|
+
|
|
695
|
+
Optional short alias: `FieldStruct.use_alias!(:FS)` defines top-level `FS` →
|
|
696
|
+
`FieldStruct` and switches inspect output to the short prefix.
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Introspection
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
User.metadata # => #<FieldStruct::Metadata fields=[:name, :age]>
|
|
704
|
+
User.metadata[:age] # => #<FieldStruct::Field :age Integer>
|
|
705
|
+
User.attribute_names # => [:name, :age]
|
|
706
|
+
field = User.metadata[:age]
|
|
707
|
+
field.name; field.type; field.required?; field.default; field.options; field.type_instance.ruby_type
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## RBS for your own classes
|
|
713
|
+
|
|
714
|
+
Sord types the library; `FieldStruct::RBS.generate` types the accessors **your**
|
|
715
|
+
`field` declarations create, so Steep/Solargraph can check `order.items`:
|
|
716
|
+
|
|
717
|
+
```ruby
|
|
718
|
+
class Order < FieldStruct::Base
|
|
719
|
+
required :id, :uuid
|
|
720
|
+
optional :items, :array, of: LineItem
|
|
721
|
+
optional :status, :union, of: %i[symbol boolean]
|
|
722
|
+
optional :scope, :value, rbs_type: '::ActiveRecord::Relation'
|
|
723
|
+
optional :extra, :value
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
puts FieldStruct::RBS.generate(Order)
|
|
727
|
+
# # Generated by FieldStruct::RBS. Types describe a valid instance: an
|
|
728
|
+
# # invalid one (a required field left unset, or a :keep_raw coercion
|
|
729
|
+
# # failure holding the raw input) can hold other values. Check valid?.
|
|
730
|
+
# class Order < ::FieldStruct::Base
|
|
731
|
+
# attr_reader id: ::String
|
|
732
|
+
# def id=: (::String? value) -> ::String?
|
|
733
|
+
#
|
|
734
|
+
# attr_reader items: ::Array[::LineItem]?
|
|
735
|
+
# def items=: (::Array[::LineItem]? value) -> ::Array[::LineItem]?
|
|
736
|
+
#
|
|
737
|
+
# attr_reader status: (::Symbol | bool)?
|
|
738
|
+
# def status=: ((::Symbol | bool)? value) -> (::Symbol | bool)?
|
|
739
|
+
#
|
|
740
|
+
# attr_reader scope: ::ActiveRecord::Relation?
|
|
741
|
+
# def scope=: (::ActiveRecord::Relation? value) -> ::ActiveRecord::Relation?
|
|
742
|
+
#
|
|
743
|
+
# attr_reader extra: untyped
|
|
744
|
+
# def extra=: (untyped value) -> untyped
|
|
745
|
+
# end
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
- **Types describe a valid instance.** Reader = the field's Ruby type; nullability
|
|
749
|
+
follows `required?` (required → `T`, optional → `T?`). An invalid instance (unset
|
|
750
|
+
required field, `:keep_raw` failure holding raw input) can hold other values.
|
|
751
|
+
- **Writer** is `(T? value) -> T?` — the setter takes `nil` and returns the coerced
|
|
752
|
+
value. Loose input it would coerce (`user.age = "30"` on an `:integer` field) runs
|
|
753
|
+
fine but is a Steep error; coerce at the boundary.
|
|
754
|
+
- **Generics are filled in:** `::Array[::LineItem]` (a scalar element is nullable: `of: :string` → `::Array[::String?]`, because `["x", nil]` is valid), unions `(A | B)` (boolean →
|
|
755
|
+
`bool`), nested structs by qualified name. A custom type whose `ruby_type` is a bare
|
|
756
|
+
generic (`::Hash`) emits `::Hash[untyped, untyped]` unless the type defines
|
|
757
|
+
`rbs_type` (`def rbs_type = '::Hash[::Symbol, ::String]'`).
|
|
758
|
+
- **`:value`** (and a union with a `:value` member) is `untyped`, reader and writer.
|
|
759
|
+
- **`rbs_type:`** (field) beats the type's `rbs_type`, which beats the derived type.
|
|
760
|
+
It replaces `T` verbatim — `?` is still added for optional readers and all
|
|
761
|
+
writers, and a union override is parenthesized first. Must be a non-blank String
|
|
762
|
+
(checked at declaration; not parsed — `rbs` isn't a runtime dependency). Fully
|
|
763
|
+
qualify constants (`::ActiveRecord::Relation`).
|
|
764
|
+
|
|
765
|
+
`FieldStruct::RBS.type_for(field, nullable: nil)` returns one field's type exactly as
|
|
766
|
+
`generate` emits it, for generators that type methods taking or returning the field:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
FieldStruct::RBS.type_for(Order.metadata[:items]) # => "::Array[::LineItem]?"
|
|
770
|
+
FieldStruct::RBS.type_for(Order.metadata[:items], nullable: false) # => "::Array[::LineItem]"
|
|
771
|
+
FieldStruct::RBS.type_for(Order.metadata[:id], nullable: true) # => "::String?"
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
`nullable: nil` (default) follows `!field.required?`; `untyped` never gets `?`.
|
|
775
|
+
Generate RBS for nested/element classes too so `::LineItem` references resolve. See
|
|
776
|
+
the README "Type signatures" section for a Rake-task wiring example.
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
780
|
+
## Gotchas / invariants
|
|
781
|
+
|
|
782
|
+
- **Setters are permissive by design** — `user.age = "30"` is valid (coerces). The
|
|
783
|
+
generated writer is typed `(T? value) -> T?`, so Steep flags that line even though
|
|
784
|
+
Ruby accepts it.
|
|
785
|
+
- **A required field can still be `nil`** (coercion failed under `:keep_raw`/`:replace`,
|
|
786
|
+
or never assigned). Check `valid?`; don't assume required ⇒ non-nil.
|
|
787
|
+
- **Whitespace-only strings are "missing"** for `:string` and its subclasses
|
|
788
|
+
(`:uuid`/`:url`/`:email`/`:immutable_string`). `:binary` is the exception — its
|
|
789
|
+
whitespace bytes are real data, so only nil/empty count as missing.
|
|
790
|
+
- **`nil` passes through coercion as `nil`** for essentially every type.
|
|
791
|
+
- **Temporal coercion behaves the same with or without ActiveSupport loaded.**
|
|
792
|
+
That is a deliberate invariant, not a coincidence — see `.claude/project_intent.md`
|
|
793
|
+
invariant 8. Coercion dispatches on explicit stdlib classes rather than on
|
|
794
|
+
`respond_to?(:to_date)` and friends, which ActiveSupport defines on every String.
|
|
795
|
+
- `:array` requires `of:`; `:union` requires `of: [...]` with ≥2 members. Options for
|
|
796
|
+
each array element go in `element: { ... }`, not on the field.
|
|
797
|
+
- Option scoping: `format:` → string-shaped + date/time/datetime; `enum:` →
|
|
798
|
+
string/symbol; `in:` → integer/float/decimal/date/time/datetime; `round:` →
|
|
799
|
+
float/decimal; `base:` → integer; `strict:` → string-shaped/integer; `values:` → boolean; `element:` → array. Misapplying raises `ArgumentError` at declaration.
|
|
800
|
+
- `description:`/`desc:` is documentation only — absent from `attributes`, `as_json`,
|
|
801
|
+
and pattern-match keys.
|
|
802
|
+
- **Input is a Hash, or anything with `to_hash`** (e.g. a permitted
|
|
803
|
+
`ActionController::Parameters`). `new(nil)` is the same as `new`; any other
|
|
804
|
+
shape (`new([])`, `new("x")`, `assign_attributes(nil)`) raises `ArgumentError`
|
|
805
|
+
naming the class it got.
|
|
806
|
+
- **`dup` / `clone` copy the errors too** — the copy starts with the original's
|
|
807
|
+
messages but owns them, so changing one's top-level fields never changes the
|
|
808
|
+
other's `valid?`. The copy is shallow: nested structs are shared.
|
|
809
|
+
- **Some field names are reserved** — the ones `Base` calls on its own instances
|
|
810
|
+
(`errors`, `attributes`, `hash`, `class`, `send`, `to_h`, `inspect`, … — see
|
|
811
|
+
`FieldStruct::Base::RESERVED_FIELD_NAMES`). Declaring one raises `ArgumentError`
|
|
812
|
+
naming it; name the field something else and map the external key with
|
|
813
|
+
`serialize :json, errors_field: 'errors'`. `method`, `name`, `type` stay legal.
|
|
814
|
+
- Unknown input keys are dropped by default (`unknown_attributes :ignore`); set
|
|
815
|
+
`:raise` to reject them with `UnknownAttributeError` — recommended at payment/API
|
|
816
|
+
boundaries, on each struct (see [above](#payment-and-api-boundaries-unknown_attributes-raise)).
|
|
817
|
+
- **Validity is computed on assignment.** In-place mutation of a nested struct or an
|
|
818
|
+
array element doesn't refresh the parent's errors; re-assign the field (see
|
|
819
|
+
[staleness](#validity-is-computed-on-assignment-shallow-valid-staleness)).
|
|
820
|
+
- Uses **Oj** for JSON (`Oj.load`/`Oj.dump`), not the stdlib JSON.
|