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/README.md
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
# FieldStruct
|
|
2
|
+
|
|
3
|
+
Typed POROs for Ruby — declare fields with enforced types, presence checks, and per-field validation. Mirrors the ActiveModel interface shape where it helps adoption, but reuses none of its code.
|
|
4
|
+
|
|
5
|
+
<!-- doctest -->
|
|
6
|
+
```ruby
|
|
7
|
+
class User < FieldStruct::Base
|
|
8
|
+
required :name, :string
|
|
9
|
+
optional :age, :integer
|
|
10
|
+
optional :tags, :array, of: :string
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
u = User.new(name: 'Alice', age: '30', tags: %w[admin staff])
|
|
14
|
+
|
|
15
|
+
u.name # => "Alice"
|
|
16
|
+
u.age # => 30
|
|
17
|
+
u.tags # => ["admin", "staff"]
|
|
18
|
+
u.valid? # => true
|
|
19
|
+
u.attributes # => { name: "Alice", age: 30, tags: ["admin", "staff"] }
|
|
20
|
+
u.to_json # => '{"name":"Alice","age":30,"tags":["admin","staff"]}'
|
|
21
|
+
|
|
22
|
+
u.name = ''
|
|
23
|
+
u.valid? # => false
|
|
24
|
+
u.errors[:name] # => ["is required"]
|
|
25
|
+
u.errors.full_messages # => ["Name is required"]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`inspect` surfaces invalidity too: a valid instance reads cleanly
|
|
29
|
+
(`#<User name: "Alice", age: 30, …>`), while an invalid one appends its errors —
|
|
30
|
+
`#<User name: "", age: 30, … errors: {name: ["is required"]}>` — so an object
|
|
31
|
+
never *looks* fine when it isn't.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
FieldStruct isn't on RubyGems yet. Add it to your Gemfile from GitHub,
|
|
36
|
+
pinned to a release tag:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
gem 'field_struct', git: 'https://github.com/Paymentbox-com/field_struct', tag: 'v0.15.0'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Ruby 3.0+.
|
|
43
|
+
|
|
44
|
+
Upgrading from an earlier version? [`UPGRADING.md`](UPGRADING.md) lists every visible change, how to find the code it affects, and the fix.
|
|
45
|
+
|
|
46
|
+
> **New here?** [`docs/getting_started.md`](docs/getting_started.md) walks through
|
|
47
|
+
> using FieldStruct in a repo (general Ruby + a Rails section). For the exhaustive
|
|
48
|
+
> reference, [`USAGE.md`](USAGE.md) is a dense, example-first cheat sheet — every type,
|
|
49
|
+
> option, and macro on one page. Both ship with the gem (`bundle show field_struct`),
|
|
50
|
+
> for editors and AI assistants to read.
|
|
51
|
+
|
|
52
|
+
## What FieldStruct is
|
|
53
|
+
|
|
54
|
+
- A typed-value-object foundation: declare attributes, get coercion + validation for free.
|
|
55
|
+
- A class-level `Metadata` collection per FieldStruct class, inspectable and introspectable.
|
|
56
|
+
- A pluggable type system, namespaced through a `Registry` that downstream code can extend or replace.
|
|
57
|
+
- ActiveModel-shaped at the public surface (`valid?`, `errors`, `attributes`, `as_json`, `to_json`, `model_name`, `to_model`) so Rails-adjacent code feels at home.
|
|
58
|
+
|
|
59
|
+
## What FieldStruct is not
|
|
60
|
+
|
|
61
|
+
- Not a database layer. It doesn't persist, query, or hold connections.
|
|
62
|
+
- Not a form object. It doesn't render or know about HTTP params.
|
|
63
|
+
- Not an ActiveModel replacement. Interface shape only — no AM code reuse.
|
|
64
|
+
- Not a hash-schema validator. Validation is per-field-on-assignment, not "check this hash against a schema."
|
|
65
|
+
|
|
66
|
+
## Declaring fields
|
|
67
|
+
|
|
68
|
+
Three macros declare fields; `field` is the primitive, `required` / `optional` are sugar that set the `required:` option.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class Account < FieldStruct::Base
|
|
72
|
+
field :id, :integer # neutral; defaults to optional
|
|
73
|
+
required :email, :string, format: /@/ # presence-checked + format
|
|
74
|
+
optional :nickname, :immutable_string # coerced, then frozen
|
|
75
|
+
optional :balance, :decimal, default: '0' # :decimal is an alias for :big_decimal
|
|
76
|
+
optional :created_at, :datetime
|
|
77
|
+
required :tags, :array, of: :string # element type is required
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Base types
|
|
82
|
+
|
|
83
|
+
`:string`, `:immutable_string`, `:integer`, `:float`, `:big_decimal` (aliased as `:decimal`), `:boolean`, `:date`, `:time`, `:datetime`, `:value`, and `:array` (parameterized via `of:`).
|
|
84
|
+
|
|
85
|
+
Extended types: `:symbol`, `:uuid`, `:url`, `:email`, `:binary`. The first four serve string-shaped use cases — `:uuid`/`:url`/`:email` pre-fill a sensible `format:` regex (overrideable per-field), `:binary` forces ASCII-8BIT encoding and treats whitespace bytes as meaningful (not "missing").
|
|
86
|
+
|
|
87
|
+
`:value` is a passthrough — useful when you want metadata for a field without committing to a shape.
|
|
88
|
+
|
|
89
|
+
### Union types
|
|
90
|
+
|
|
91
|
+
A field that may hold any of several types — each tried in declared order, first success wins:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class Event < FieldStruct::Base
|
|
95
|
+
optional :payload, :union, of: [Payload, :boolean]
|
|
96
|
+
optional :id, :union, of: %i[integer string]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Event.new(payload: { kind: 'click', value: 1 }).payload # => #<Payload ...>
|
|
100
|
+
Event.new(payload: true).payload # => true
|
|
101
|
+
Event.new(id: '42').id # => "42" (string first)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Members can be Symbols (registered scalars or FieldStruct subclasses) or Class arguments (FieldStruct subclasses). If every member rejects the value, the union raises and the parent's `coercion_policy` engages. Declared order matters — pick deliberately when types overlap (`Integer` accepts `"42"`, `String` accepts `"42"` — the one listed first wins).
|
|
105
|
+
|
|
106
|
+
### Nested FieldStructs
|
|
107
|
+
|
|
108
|
+
Pass a `FieldStruct::Base` subclass as the type to nest:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class Address < FieldStruct::Base
|
|
112
|
+
required :street, :string
|
|
113
|
+
required :city, :string
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class Person < FieldStruct::Base
|
|
117
|
+
required :name, :string
|
|
118
|
+
required :address, Address
|
|
119
|
+
optional :addresses, :array, of: Address # arrays of nested too
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Person.new(name: 'Alice', address: {street: '1', city: 'NYC'}).address
|
|
123
|
+
# => #<Address street: "1", city: "NYC">
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- Accepts nil / a struct instance / a Hash (`Address.new(hash)` happens automatically).
|
|
127
|
+
- If the nested struct is invalid at assignment time, the parent gets `errors[:address] = ['is invalid']`. Drill into `parent.address.errors` for the per-field breakdown.
|
|
128
|
+
- Validity is computed **on assignment**. Mutating a nested struct (or an array element) in place doesn't refresh the parent's errors; assign the field again, or use `with`, to re-validate. See [USAGE: shallow `valid?` staleness](USAGE.md#validity-is-computed-on-assignment-shallow-valid-staleness).
|
|
129
|
+
- Inner construction errors (e.g. `FieldStruct::UnknownAttributeError` from a nested class with `unknown_attributes :raise`) propagate to the caller rather than being caught by the parent's `coercion_policy`.
|
|
130
|
+
- `as_json` / `to_json` deep-walk nested structures.
|
|
131
|
+
|
|
132
|
+
### What "required" means
|
|
133
|
+
|
|
134
|
+
A required field is invalid when its **value** is missing after coercion — the *type* decides what "missing" means:
|
|
135
|
+
|
|
136
|
+
| Type | Missing if |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `:string`, `:immutable_string` | nil, empty, or whitespace-only |
|
|
139
|
+
| `:integer`, `:float`, `:decimal` | nil only (`0` is valid) |
|
|
140
|
+
| `:boolean` | nil only (`false` is valid) |
|
|
141
|
+
| `:date`, `:time`, `:datetime` | nil only |
|
|
142
|
+
| `:array` | nil or empty |
|
|
143
|
+
| `:value` | nil only |
|
|
144
|
+
|
|
145
|
+
Defaults satisfy required — the value is what's checked, not whether the input hash contained the key.
|
|
146
|
+
|
|
147
|
+
`default:` accepts a literal or any callable (Proc, Lambda, Method) that takes no arguments. The callable is invoked once per instance, so it's safe to use for per-instance values like timestamps or generated IDs:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
optional :created_at, :datetime, default: Time.method(:now)
|
|
151
|
+
optional :token, :string, default: -> { "tok-#{SecureRandom.hex(4)}" }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Documenting fields with `description:` / `desc:`
|
|
155
|
+
|
|
156
|
+
Attach a human-readable description to each field for downstream documentation generators. Aliased — pick whichever reads better at the call site:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class User < FieldStruct::Base
|
|
160
|
+
required :email, :string, description: 'Primary contact address'
|
|
161
|
+
required :first_name, :string, desc: 'Given name'
|
|
162
|
+
optional :age, :integer
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
User.metadata[:email].description # => 'Primary contact address'
|
|
166
|
+
User.metadata[:email].desc # => same
|
|
167
|
+
User.metadata[:age].description # => nil
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The description is documentation metadata — it does **not** appear in `attributes` / `as_json` / `to_json` / pattern matches. Downstream gems can walk `Klass.metadata` to emit docs in any format. Passing both `description:` and `desc:` to the same field raises `ArgumentError`.
|
|
171
|
+
|
|
172
|
+
### Per-type field options
|
|
173
|
+
|
|
174
|
+
Each type accepts a small set of customization options at the field level. Values can be literals or Symbol presets (resolved at class-load time against the type's `presets` Hash).
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class Order < FieldStruct::Base
|
|
178
|
+
required :total, :decimal, round: 2 # round to 2 decimals
|
|
179
|
+
required :discount, :float, round: 4
|
|
180
|
+
required :sku_hex, :integer, base: 16 # "1A" → 26 (strings default to base 10)
|
|
181
|
+
required :paid, :boolean, values: :english_yes_no # symbol preset
|
|
182
|
+
required :priority, :boolean, values: {truthy: %w[high urgent], falsy: %w[low normal]}
|
|
183
|
+
required :placed_at, :datetime, format: :db # '%Y-%m-%d %H:%M:%S'
|
|
184
|
+
required :ship_by, :date, format: '%m/%d/%Y' # explicit strftime/strptime
|
|
185
|
+
required :customer_id, :uuid, format: :v4 # v4-only regex
|
|
186
|
+
required :contact, :email, format: :strict # stricter regex preset
|
|
187
|
+
required :cents, :integer, strict: true # 12.7 refused, not truncated
|
|
188
|
+
required :starts_at, :time, zone: '-06:00' # zone-less input read at -06:00
|
|
189
|
+
optional :ids, :array, of: :integer, element: {strict: true} # options for each element
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Two field options work on any type: `sensitive: true` shows `[FILTERED]` in `inspect` / `pp` while readers, `to_h` and `to_json` keep the real value, and error messages never echo input values at all (see [Log safety](USAGE.md#log-safety-error-messages-and-sensitive-fields)).
|
|
194
|
+
|
|
195
|
+
What each type supports:
|
|
196
|
+
|
|
197
|
+
| Type | Option | Form | Built-in presets |
|
|
198
|
+
|---|---|---|---|
|
|
199
|
+
| `:big_decimal` / `:decimal`, `:float` | `round:` | Integer (half up; result keeps the field's type) | — |
|
|
200
|
+
| `:integer` | `base:` | Integer 2..36 (string radix; default 10) | — |
|
|
201
|
+
| `:string` (and subtypes), `:integer` | `strict:` | Boolean — refuse loose input instead of `to_s` / truncating ([Strict coercion](USAGE.md#strict-coercion-strict-true)) | — |
|
|
202
|
+
| `:array` | `element:` | Hash of the element type's own options, applied to each element ([Element options](USAGE.md#element-options-element)) | — |
|
|
203
|
+
| `:time`, `:datetime` | `zone:` | `"UTC"`, `"±HH:MM"` or an IANA name (`"America/Denver"`, DST-aware) — the zone for zone-less input ([Zones](USAGE.md#zones-zone)) | — |
|
|
204
|
+
| `:boolean` | `values:` | `{truthy:, falsy:}` Hash, or Symbol | `:english_yes_no`, `:english`, `:numeric` |
|
|
205
|
+
| `:date` | `format:` | strftime String, or Symbol | `:iso8601`, `:us`, `:eu` |
|
|
206
|
+
| `:datetime`, `:time` | `format:` | strftime String, or Symbol | `:iso8601`, `:rfc2822`, `:db` |
|
|
207
|
+
| `:email` | `format:` | Regexp, or Symbol | `:permissive`, `:default`, `:strict` |
|
|
208
|
+
| `:uuid` | `format:` | Regexp, or Symbol | `:any_version`, `:v4`, `:v7` |
|
|
209
|
+
| `:url` | `format:` | Regexp, or Symbol | `:http`, `:https_only`, `:any_scheme` |
|
|
210
|
+
|
|
211
|
+
You don't have to memorize this table — **each type describes its own options at runtime**. `FieldStruct.types.lookup(:date).option_schema` returns `{ format: {type:, required:, presets:}, in: {...} }`. The DSL validates these native options at class-load: a wrong-shaped value (`round: '2'`) or a misapplied option (`enum:` on `:integer`) raises with the expected shape / the types it applies to. An option the type doesn't recognize is **not** an error — it's stored verbatim on the `Field`, so downstream tooling (e.g. an Avro exporter) can carry its own options.
|
|
212
|
+
|
|
213
|
+
Time-shaped `format:` applies in **both** directions: input strings parse via `strptime`, output (`as_json` / `to_json`) emits via `strftime`. The exception is `:iso8601`, which FieldStruct parses and renders itself as RFC 3339 (`Types::Rfc3339Format`) rather than through a strftime string — no strftime string can express an optional fractional second, and `%z` emits `+0000` where RFC 3339 requires `+00:00` or `Z`. The round-trip property holds either way — `Klass.from_json(instance.to_json) == instance` for any format you pick.
|
|
214
|
+
|
|
215
|
+
You can override the type's defaults class-wide by subclassing and overriding the relevant `default_*` or `presets` method, then registering the subclass:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
class USDate < FieldStruct::Types::Date
|
|
219
|
+
def self.default_format = '%m/%d/%Y'
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
module Acme
|
|
223
|
+
def self.field_types
|
|
224
|
+
@field_types ||= FieldStruct.new_registry { register :us_date, USDate }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Numeric coercion is strict about what a number is: `:integer` reads strings in base 10 (`"010"` is 10, `"0x1A"` is refused) unless the field declares `base:`; `:integer`, `:float` and `:decimal` refuse NaN and ±Infinity (including JSON `1e400`) as a coercion failure; and `round:` rounds half up whatever the global `BigDecimal.mode`, returning a `Float` / `BigDecimal` even at `round: 0`.
|
|
230
|
+
|
|
231
|
+
### Restricting values — `enum:` and `in:`
|
|
232
|
+
|
|
233
|
+
Two parallel options for "the coerced value must be one of these":
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
required :status, :string, enum: %w[on off]
|
|
237
|
+
required :position, :symbol, enum: %i[before after]
|
|
238
|
+
required :page_size, :integer, in: [10, 20, 30]
|
|
239
|
+
required :amount, :float, in: 1.0..10.0
|
|
240
|
+
required :height, :integer, in: 10..
|
|
241
|
+
required :start_on, :date, in: Date.new(2024, 1, 1)..Date.new(2024, 12, 31)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- `enum: [...]` — string-like types (`:string`, `:symbol`, `:uuid`, etc.). Array of allowed values.
|
|
245
|
+
- `in:` — rangy types (`:integer`, `:float`, `:decimal`, `:date`, `:time`, `:datetime`). An Array is a membership test; a Range is a bounds check via `cover?`, so closed and half-open both work and a `:date` / `:datetime` range compares endpoints instead of enumerating days.
|
|
246
|
+
- Both run post-coercion (`'10'` becomes `10` before the check) and emit `'is invalid'` on mismatch.
|
|
247
|
+
- Passing `enum:` to a rangy field — or `in:` to a string-like field — raises `ArgumentError` at class load.
|
|
248
|
+
|
|
249
|
+
## Class macros
|
|
250
|
+
|
|
251
|
+
Every macro is inherited by descendants and overridable.
|
|
252
|
+
|
|
253
|
+
### `coercion_policy`
|
|
254
|
+
|
|
255
|
+
What happens when a value can't be coerced into the declared type:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
class Strict < FieldStruct::Base
|
|
259
|
+
coercion_policy :raise # :keep_raw (default) | :replace | :raise
|
|
260
|
+
required :age, :integer
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
Strict.new(age: 'abc') # raises FieldStruct::CoercionError
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
- `:keep_raw` — store the raw uncoercible value, record `"could not be coerced: ..."`
|
|
267
|
+
- `:replace` — store `nil`, record the same error
|
|
268
|
+
- `:raise` — raise `FieldStruct::CoercionError` from the setter
|
|
269
|
+
|
|
270
|
+
A single field can override the class-level policy via `coercion_policy:`:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
class Mixed < FieldStruct::Base
|
|
274
|
+
coercion_policy :keep_raw # class default
|
|
275
|
+
required :strict_id, :integer, coercion_policy: :raise
|
|
276
|
+
optional :lenient_count, :integer # inherits :keep_raw
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### `immutable!`
|
|
281
|
+
|
|
282
|
+
Block reassignment after construction. Default mutable.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
class Config < FieldStruct::Base
|
|
286
|
+
immutable!
|
|
287
|
+
required :api_key, :string
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
c = Config.new(api_key: 'sk-...')
|
|
291
|
+
c.api_key = 'oops' # raises FieldStruct::ImmutableError
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### `frozen!`
|
|
295
|
+
|
|
296
|
+
Make instances Ruby-frozen at the end of construction. Stricter than `immutable!` — any ivar mutation raises `FrozenError` (Ruby's mechanism, not ours).
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
class FrozenConfig < FieldStruct::Base
|
|
300
|
+
frozen!
|
|
301
|
+
required :api_key, :string
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
c = FrozenConfig.new(api_key: 'sk-...')
|
|
305
|
+
c.frozen? # => true
|
|
306
|
+
c.api_key = 'x' # => raises FrozenError
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Independent of `immutable!` — pick `immutable!` for our custom error and check, `frozen!` for Ruby's built-in freeze. Both can stack.
|
|
310
|
+
|
|
311
|
+
### `unknown_attributes`
|
|
312
|
+
|
|
313
|
+
How `initialize` / `assign_attributes` respond to input keys that don't match any declared field:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
class Strict < FieldStruct::Base
|
|
317
|
+
unknown_attributes :raise # :ignore (default) | :raise
|
|
318
|
+
required :name, :string
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
Strict.new(name: 'Alice', extra: 'x') # raises FieldStruct::UnknownAttributeError
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Use `:raise` at payment and API boundaries, on every struct that reads input (nested classes don't inherit it from the struct that holds them). Otherwise a misspelled or renamed key is dropped without a word. See [USAGE: Payment and API boundaries](USAGE.md#payment-and-api-boundaries-unknown_attributes-raise).
|
|
325
|
+
|
|
326
|
+
### Inheritance
|
|
327
|
+
|
|
328
|
+
All four macros inherit to subclasses. A child overrides one by re-declaring it; the override is isolated — it never changes the parent.
|
|
329
|
+
|
|
330
|
+
<!-- doctest -->
|
|
331
|
+
```ruby
|
|
332
|
+
class Account < FieldStruct::Base
|
|
333
|
+
coercion_policy :raise
|
|
334
|
+
immutable!
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
class GuestAccount < Account
|
|
338
|
+
coercion_policy :keep_raw # override — Account stays :raise
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
GuestAccount.coercion_policy # => :keep_raw
|
|
342
|
+
Account.coercion_policy # => :raise
|
|
343
|
+
GuestAccount.immutable? # => true
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| Macro | Inherited | Override on a child |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `coercion_policy` | ✅ | any value |
|
|
349
|
+
| `unknown_attributes` | ✅ | `:ignore` ↔ `:raise` |
|
|
350
|
+
| `immutable!` | ✅ | can add, **not remove** (one-way; no `mutable!`) |
|
|
351
|
+
| `frozen!` | ✅ | can add, **not remove** (one-way; no `unfrozen!`) |
|
|
352
|
+
|
|
353
|
+
Because `immutable!` / `frozen!` tighten but don't loosen, put them on the leaf classes that need them — not a shared parent — if some subclasses must stay mutable.
|
|
354
|
+
|
|
355
|
+
## Namespace registries
|
|
356
|
+
|
|
357
|
+
Each FieldStruct class resolves type names through a registry chain. Define a module-level `field_types` method to extend the default set inside a namespace:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
module Acme
|
|
361
|
+
def self.field_types
|
|
362
|
+
@field_types ||= FieldStruct.new_registry do
|
|
363
|
+
register :money, Acme::Types::Money
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
class Order < FieldStruct::Base
|
|
368
|
+
required :price, :money # resolved through Acme's registry, with FieldStruct.types as fallback
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`FieldStruct.new_registry` builds a new registry parented to `FieldStruct.types` and evaluates the block in the new registry's instance scope, so `register` works without a receiver. Pass `nil` for an unparented registry, or a different parent for a custom chain.
|
|
374
|
+
|
|
375
|
+
Lookup walks the class's containing modules from innermost outward, then falls back to `FieldStruct.types`.
|
|
376
|
+
|
|
377
|
+
## Cross-field validation
|
|
378
|
+
|
|
379
|
+
For checks that span more than one field, declare a `validate` block or point to an instance method. Both forms can be mixed and stacked.
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
class Schedule < FieldStruct::Base
|
|
383
|
+
required :start_date, :date
|
|
384
|
+
required :end_date, :date
|
|
385
|
+
|
|
386
|
+
validate :ensure_chronological
|
|
387
|
+
validate do |record|
|
|
388
|
+
record.errors.add(:base, 'must span at least one day') if record.end_date == record.start_date
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def ensure_chronological
|
|
392
|
+
return unless start_date && end_date
|
|
393
|
+
|
|
394
|
+
errors.add(:base, 'end_date must not precede start_date') if start_date > end_date
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
bad = Schedule.new(start_date: '2024-02-01', end_date: '2024-01-15')
|
|
399
|
+
bad.errors[:base] # => ['end_date must not precede start_date', 'must span at least one day']
|
|
400
|
+
bad.valid? # => false
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
- Validators run on `valid?` (and once at the end of `initialize`, so a fresh instance has `errors[:base]` populated for inspection).
|
|
404
|
+
- `errors[:base]` is cleared at the start of each cross-field run, so stale entries don't pile up.
|
|
405
|
+
- Field-level errors written by setters (Phase 1's "setter owns its field's errors") are **not** cleared by `valid?` — only `:base` is.
|
|
406
|
+
- Validators are inherited by subclasses (the subclass receives a `dup` of the parent's list and can append).
|
|
407
|
+
- Symbol form `validate :name, :other` registers each as its own validator.
|
|
408
|
+
|
|
409
|
+
## Serialization — bridging external naming conventions
|
|
410
|
+
|
|
411
|
+
When a payload arrives with names that don't match your Ruby conventions — `firstName` from a vendor API, `EMAIL_ADDR` from a legacy DB — declare a `serialize` block on the class. The mapping is internal-symbol → external-string and applies to both directions (import reverse-maps, export forward-maps).
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
class User < FieldStruct::Base
|
|
415
|
+
required :email, :string
|
|
416
|
+
required :first_name, :string
|
|
417
|
+
required :last_name, :string
|
|
418
|
+
|
|
419
|
+
serialize :json,
|
|
420
|
+
first_name: 'firstName',
|
|
421
|
+
last_name: 'lastName'
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Import — JSON keys are reverse-mapped to canonical fields.
|
|
425
|
+
user = User.from_json('{"email":"a@b.com","firstName":"Alice","lastName":"Smith"}')
|
|
426
|
+
user.first_name # => "Alice"
|
|
427
|
+
|
|
428
|
+
# Export — to_json applies the mapping forward.
|
|
429
|
+
user.to_json
|
|
430
|
+
# => '{"email":"a@b.com","firstName":"Alice","lastName":"Smith"}'
|
|
431
|
+
|
|
432
|
+
# Round-trip: User.from_json(user.to_json) == user.
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
- Fields not listed in the mapping (here `:email`) use their canonical name unchanged.
|
|
436
|
+
- `to_h` and `attributes` always return canonical names — only `to_json` / `as_json` / `from_json` consult the mapping.
|
|
437
|
+
- Each mapping key must be a declared field; an unknown key raises `ArgumentError` at class load. Declare fields first, then `serialize`.
|
|
438
|
+
- The mapping is inherited by subclasses; redeclaring on a subclass replaces it (last-write-wins).
|
|
439
|
+
- Nested FieldStructs apply their own mapping — including struct members of a `:union`. `from_json` tries the union's members in declared order, reverse-mapping the object through each struct member's mapping, so give struct members `unknown_attributes :raise` if the union must tell them apart.
|
|
440
|
+
- Multiple formats can coexist on the same class (`serialize :json, ...` + `serialize :csv, ...`). Only `:json` is wired in-gem; CSV / XML / Avro support is left for downstream gems that read `Klass.metadata.serializations[:their_name]`.
|
|
441
|
+
- Nested FieldStructs and arrays of nested propagate naturally — each level uses its own serialize mapping.
|
|
442
|
+
|
|
443
|
+
## Parsing JSON
|
|
444
|
+
|
|
445
|
+
`Klass.from_json(string)` parses with Oj and feeds the resulting hash through `.new` — so coercion, nested construction, `unknown_attributes`, and `coercion_policy` all engage the same way they do for direct calls. When the class has a `serialize :json` mapping, external keys are reverse-mapped to canonical names before construction.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
person = Person.from_json('{"name":"Alice","address":{"street":"1","city":"NYC"}}')
|
|
449
|
+
person.address.city # => "NYC"
|
|
450
|
+
|
|
451
|
+
# Round-trips for every base type except :value (no type info to preserve).
|
|
452
|
+
restored = Person.from_json(person.to_json)
|
|
453
|
+
restored == person # => true
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
A non-object root (`[...]`, `"hi"`, `42`, `null`) raises `ArgumentError`; malformed JSON propagates the underlying Oj parse error.
|
|
457
|
+
|
|
458
|
+
## Pattern matching
|
|
459
|
+
|
|
460
|
+
Every FieldStruct instance works as a Ruby 3.0+ pattern target out of the box. Hash patterns slice into the declared fields by canonical name; array patterns bind values in declaration order.
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
case user
|
|
464
|
+
in { name: 'Alice', age: Integer => age }
|
|
465
|
+
"Alice is #{age}"
|
|
466
|
+
in { age: ..18 }
|
|
467
|
+
'minor'
|
|
468
|
+
in { name: }
|
|
469
|
+
"found #{name}"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Nested FieldStructs recurse naturally
|
|
473
|
+
case order
|
|
474
|
+
in { customer: { email: }, items: [_, *] }
|
|
475
|
+
"order from #{email} with multiple items"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Array pattern uses declared field order
|
|
479
|
+
case point # required :x, :integer; required :y, :integer
|
|
480
|
+
in [x, y]
|
|
481
|
+
Math.hypot(x, y)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Find pattern over an array of nested
|
|
485
|
+
case team
|
|
486
|
+
in { members: [*, { role: :admin, name: } => _admin, *] }
|
|
487
|
+
"admin: #{name}"
|
|
488
|
+
end
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Aliases and `errors` do **not** participate in pattern matching — patterns are Ruby-side and use canonical field names. Frozen and immutable instances pattern-match the same way as mutable ones (reads only).
|
|
492
|
+
|
|
493
|
+
## ActiveModel-shaped surface
|
|
494
|
+
|
|
495
|
+
Mirroring AM's call sites so Rails-adjacent code feels at home:
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
u = User.new(name: 'Alice')
|
|
499
|
+
|
|
500
|
+
u.valid? # / u.invalid?
|
|
501
|
+
u.errors # FieldStruct::Errors ([] / add / clear / empty? / to_h / messages / full_messages)
|
|
502
|
+
u.attributes # / u.attribute_names
|
|
503
|
+
u.assign_attributes(name: 'Bob')
|
|
504
|
+
u.with(name: 'Carol') # a NEW instance with name changed; u untouched (like Data#with)
|
|
505
|
+
u.to_h # alias of #attributes
|
|
506
|
+
u.as_json # JSON-ready hash (Date/Time -> ISO-8601, BigDecimal -> string)
|
|
507
|
+
u.to_json # via Oj
|
|
508
|
+
u.inspect # #<User name: "Alice"> (appends `errors: {…}` when invalid)
|
|
509
|
+
u.model_name # FieldStruct::ModelName (name / singular / plural / element)
|
|
510
|
+
u.to_model # self
|
|
511
|
+
u == other # structural equality (class + attributes)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Type signatures
|
|
515
|
+
|
|
516
|
+
The gem ships with RBS signatures at `sig/field_struct.rbs`, generated from YARD comments via [Sord](https://github.com/AaronC81/sord). Downstream code using Solargraph, Steep, or other RBS-aware tools gets type info for the public surface for free.
|
|
517
|
+
|
|
518
|
+
```bash
|
|
519
|
+
bundle exec rake sigs:generate # regenerate from current YARD
|
|
520
|
+
bundle exec rake sigs:validate # rbs validate everything under sig/
|
|
521
|
+
bundle exec rake sigs:check # CI guard: error if sigs are stale
|
|
522
|
+
bundle exec rake sigs:untyped # CI guard: untyped count must equal the committed ceiling
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The generated file is committed to git. Improve YARD when you want to improve the sig — don't edit `sig/field_struct.rbs` by hand. Run `rake sigs:check` before pushing if you changed any YARD.
|
|
526
|
+
|
|
527
|
+
The sig can only get more typed. `sigs:untyped` fails when the non-comment `untyped` count in `sig/` rises above `rakelib/sig_guards/untyped_ceiling.txt`, and also when it falls below it — lower the ceiling in the same PR. `sigs:generate` / `sigs:check` fail on any Sord `[OMIT]` (no YARD type) or `[DUCK]` warning not listed in `rakelib/sig_guards/sord_allowlist.txt`, and on a listed one Sord no longer emits, so the list only shrinks. A report-only `steep check` runs in CI (`gemfiles/types.gemfile`, Ruby >= 3.3); it never fails the build.
|
|
528
|
+
|
|
529
|
+
Sord covers the **library** classes only. The per-field accessors that the `field` DSL defines on **your** subclasses (`user.name`, `user.age=`) don't exist until you declare the fields, so Sord can't see them — that's the other half of [D13](docs/origin/plan.md).
|
|
530
|
+
|
|
531
|
+
### Signatures for your own FieldStruct classes
|
|
532
|
+
|
|
533
|
+
`FieldStruct::RBS.generate` walks a subclass's metadata and emits RBS for its generated accessors, so Steep / Solargraph can type-check code that touches them:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
class User < FieldStruct::Base
|
|
537
|
+
required :name, :string
|
|
538
|
+
optional :age, :integer
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
puts FieldStruct::RBS.generate(User)
|
|
542
|
+
# # Generated by FieldStruct::RBS. Types describe a valid instance: ...
|
|
543
|
+
# class User < ::FieldStruct::Base
|
|
544
|
+
# attr_reader name: ::String
|
|
545
|
+
# def name=: (::String? value) -> ::String?
|
|
546
|
+
#
|
|
547
|
+
# attr_reader age: ::Integer?
|
|
548
|
+
# def age=: (::Integer? value) -> ::Integer?
|
|
549
|
+
# end
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The types describe a **valid** instance: readers carry the field's coerced Ruby type, with nullability from `required?` (required → `T`, optional → `T?`); an invalid instance can hold other values. Setters are `(T? value) -> T?` — they accept `nil` and return the coerced value. Arrays, unions and nested structs carry their element types (`::Array[::LineItem]`, `(::String | bool)`, `::Address`) — generate RBS for referenced classes too so they resolve. `:value` fields are `untyped`. Override a field's type with `rbs_type: '::ActiveRecord::Relation'`, or a custom type's with `def rbs_type`. `FieldStruct::RBS.type_for(field)` returns one field's type for other generators. Details and examples in [USAGE.md](USAGE.md#rbs-for-your-own-classes).
|
|
553
|
+
|
|
554
|
+
Wire it into your project with a Rake task that writes a sig file per class — for example:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
# lib/tasks/field_struct_rbs.rake
|
|
558
|
+
namespace :field_struct do
|
|
559
|
+
desc 'Generate RBS for the app\'s FieldStruct classes'
|
|
560
|
+
task rbs: :environment do
|
|
561
|
+
classes = [User, Account, Order] # the FieldStruct subclasses you want typed
|
|
562
|
+
File.write('sig/field_structs.rbs', classes.map { |k| FieldStruct::RBS.generate(k) }.join("\n"))
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
## Scaffolding a class from JSON
|
|
568
|
+
|
|
569
|
+
`FieldStruct::Scaffold.from_json` turns a JSON example (or several) into starter FieldStruct source — handy when modeling an existing API or webhook. It's a **working prototype you refine**: it nails the structure and the obvious types and flags the rest with comments, leaving enums, descriptions, and final scalar types to you.
|
|
570
|
+
|
|
571
|
+
```ruby
|
|
572
|
+
puts FieldStruct::Scaffold.from_json(<<~JSON, class_name: 'User')
|
|
573
|
+
{"name": "Al", "age": 30, "active": true, "ref": "00421"}
|
|
574
|
+
JSON
|
|
575
|
+
# class User < FieldStruct::Base
|
|
576
|
+
# required :name, :string
|
|
577
|
+
# required :age, :integer
|
|
578
|
+
# required :active, :boolean
|
|
579
|
+
# required :ref, :string # numeric-looking (e.g. "00421") — :integer? or keep :string if an id/code
|
|
580
|
+
# end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
It's deliberately conservative: JSON objects become nested classes (emitted before their parent), arrays become `:array, of: …`, real JSON booleans/numbers map to `:boolean`/`:integer`/`:float`, and **strings stay `:string`** — including numeric-looking ones, because an `authorization_code` must not silently become an Integer. The guesses are surfaced as trailing comments, not committed types.
|
|
584
|
+
|
|
585
|
+
Pass an **array of same-shape objects** to gather more signal: empty fields get resolved from samples that have them, presence across samples drives `required` vs `optional`, and small repeated vocabularies surface as `# values: [...] — enum?` hints.
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
FieldStruct::Scaffold.from_json('[{"kind":"cc"},{"kind":"ach"},{"kind":"cc"}]')
|
|
589
|
+
# … optional :kind, :string # values: ["cc", "ach"] — enum?
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
For the full scaffold-then-refine workflow (and how it fits a Rails integration), see [`docs/getting_started.md`](docs/getting_started.md).
|
|
593
|
+
|
|
594
|
+
## Claude Code skill
|
|
595
|
+
|
|
596
|
+
The gem bundles a [Claude Code](https://claude.com/claude-code) skill (`skills/field-struct/SKILL.md`) that teaches the assistant the DSL, the common mistakes, and how to debug an invalid instance — so editing FieldStruct models in a dependent project goes correctly.
|
|
597
|
+
|
|
598
|
+
**Install as a plugin** (recommended — the repo doubles as a single-plugin marketplace):
|
|
599
|
+
|
|
600
|
+
```text
|
|
601
|
+
/plugin marketplace add Paymentbox-com/field_struct
|
|
602
|
+
/plugin install field-struct@field-struct
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
**Or copy it into your project** (no marketplace needed) — the skill ships in the gem package:
|
|
606
|
+
|
|
607
|
+
```bash
|
|
608
|
+
mkdir -p .claude/skills/field-struct
|
|
609
|
+
cp "$(bundle show field_struct)/skills/field-struct/SKILL.md" .claude/skills/field-struct/
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Either way it triggers when you ask Claude to define, edit, or debug a `FieldStruct::Base` subclass. The skill points at [`USAGE.md`](USAGE.md) (also shipped) for the exhaustive reference.
|
|
613
|
+
|
|
614
|
+
## Performance
|
|
615
|
+
|
|
616
|
+
Indicative numbers from `bundle exec rake bench` (`benchmarks/run.rb`, no dependencies; `BENCH_SECONDS=n` sets the time per case). Ruby 4.0.7 on an Apple M4, FieldStruct 0.13.0. Mean time per call. Inputs are already the right types, except that `from_json` parses a string:
|
|
617
|
+
|
|
618
|
+
| case | µs/call | calls/sec |
|
|
619
|
+
|---|---:|---:|
|
|
620
|
+
| `new` (5 fields) | 8.1 | 124,000 |
|
|
621
|
+
| `new` (5 fields, `strict: true`) | 8.2 | 122,000 |
|
|
622
|
+
| `from_json` (5 fields) | 10.4 | 96,000 |
|
|
623
|
+
| `to_json` (5 fields) | 2.3 | 445,000 |
|
|
624
|
+
| `new` (nested struct) | 9.4 | 106,000 |
|
|
625
|
+
| `new` (array of 100 integers) | 13.3 | 75,000 |
|
|
626
|
+
| `new` (validation-heavy: `email`, `enum:`, `in:`, `format:`, 2 dates, a `validate` block) | 18.7 | 53,000 |
|
|
627
|
+
|
|
628
|
+
`strict: true` costs nothing measurable. Every assignment runs coercion and validation, so the cost scales with the number of fields assigned rather than with the size of the class. Numbers vary with CPU and Ruby version. Compare runs on one machine.
|
|
629
|
+
|
|
630
|
+
## Development
|
|
631
|
+
|
|
632
|
+
```bash
|
|
633
|
+
bundle install
|
|
634
|
+
bin/rspec # run specs
|
|
635
|
+
COVERAGE=1 bin/rspec # run specs with SimpleCov
|
|
636
|
+
bundle exec rubocop # lint
|
|
637
|
+
bundle exec rake # all of the above
|
|
638
|
+
bundle exec rake bench # indicative benchmarks (not part of the default task)
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
The plan-of-record for Phase 1 lives in [`docs/origin/plan.md`](docs/origin/plan.md).
|
|
642
|
+
|
|
643
|
+
## Contributing
|
|
644
|
+
|
|
645
|
+
Bug reports and pull requests are welcome at <https://github.com/Paymentbox-com/field_struct>. Contributors are expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|