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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. 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).