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/UPGRADING.md ADDED
@@ -0,0 +1,743 @@
1
+ # Upgrading FieldStruct
2
+
3
+ <!-- upgrading-range: 0.10.0..0.15.0 -->
4
+
5
+ What changes when you move a project from **0.10.x to 0.11, 0.12, 0.13, 0.14 or 0.15**, how
6
+ to find the code each change touches, and how to fix it. Written to be followed
7
+ step by step by a person or an AI agent. [`CHANGELOG.md`](CHANGELOG.md) has the
8
+ full history; this page is only what a caller can see.
9
+
10
+ ## TL;DR
11
+
12
+ 1. **Who must act:** every project on field_struct 0.10.x. Most will change a few spec expectations and nothing else; Steep users also regenerate RBS.
13
+ 2. **Bump** the tag in your Gemfile (`tag: 'v0.15.0'`). If you also use **actionable**: its gemspec from v1.6.0 on requires `field_struct >= 0.12` (v1.5.x needs `>= 0.11`), so bump field_struct first or Bundler will refuse to resolve.
14
+ 3. **Run your suite**, then match each failure against the [symptom index](#symptom-index) and apply that entry's Fix.
15
+ 4. **Run the Find of every silent entry, even if the suite is green.** These change a value or a field's validity without raising, so a passing suite proves nothing:
16
+ [base 10](#integer-strings-parse-in-base-10), [`round: 0`](#round-0-keeps-the-fields-type),
17
+ [`BigDecimal.mode`](#decimal-rounding-ignores-bigdecimalmode), [NaN/Infinity](#nan-and-infinity-are-refused),
18
+ [BigDecimal from JSON](#from_json-reads-decimals-as-bigdecimal), [RangeError](#rangeerror-is-a-coercion-failure),
19
+ [`dup` errors](#dup-and-clone-own-their-errors), [hex `:float`](#float-refuses-hex-strings)
20
+ and, from 0.15, [the skipped nil default](#new-skips-the-nil-default-for-keys-it-is-given)
21
+ and [`as_json` reading the fields](#as_json-reads-the-fields-not-attributes).
22
+ Every other entry raises or fails a spec. Its Find is for code your suite doesn't reach, so run it when coverage there is thin.
23
+ 5. **Regenerate any RBS you produce** with `FieldStruct::RBS.generate`, or with your project's task that wraps it (`rg -n 'FieldStruct::RBS' lib Rakefile`). Fix hand-written sigs that copied the old output ([0.12](#012--generated-rbs-changes-no-runtime-changes)).
24
+ 6. **How long:** the fixes take minutes to an hour for a typical app; most are renames, a `.to_h`, and updated message expectations. On top of that come three full suite runs (a baseline, one after the bump, and a final run), so budget 3× your suite's wall time.
25
+
26
+ Find patterns use [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`); the
27
+ ones with `-P` need ripgrep built with PCRE2. Check first, in the shell that will
28
+ run the commands, with `rg --pcre2-version`; an agent's `bash -c` may not see a
29
+ shell function or alias that wraps `rg`. Run them from your project root. A
30
+ pattern finds *candidates*; read each hit, and use the narrowing rule an entry gives.
31
+
32
+ ## Straight from 0.10 to 0.15
33
+
34
+ You don't have to stop at 0.11–0.14: bump straight to `v0.15.0`, then walk
35
+ the [0.11](#011--correctness-and-safer-input), [0.12](#012--generated-rbs-changes-no-runtime-changes),
36
+ [0.13](#013--safer-input-and-log-safety), [0.14](#013--014) and [0.15](#014--015) sections
37
+ in that order. Run the Find of every silent entry (TL;DR step 4) and read each hit;
38
+ the other entries raise or fail a spec, so the suite finds them. The [reserved field names](#reserved-field-names) pattern already includes
39
+ the names 0.13 added, so a hit there covers both versions. If the failures are
40
+ hard to attribute, [bisect by version](#if-something-else-breaks).
41
+
42
+ ## Symptom index
43
+
44
+ | You see | Version | Entry |
45
+ |---|---|---|
46
+ | `ArgumentError: field :x is reserved: FieldStruct::Base relies on #x …` at class load | 0.11, 0.13 | [Reserved field names](#reserved-field-names), [`with` / `dup`](#with-and-dup-are-reserved-field-names) |
47
+ | `ArgumentError: X.new expects a Hash (or an object responding to #to_hash), got Array` | 0.11 | [Non-Hash input](#new-and-assign_attributes-refuse-non-hash-input) |
48
+ | `ArgumentError: base: option does not apply to String fields. It applies to: Integer.` | 0.11 | [`base:` is reserved](#base-is-a-reserved-option-name) |
49
+ | `ArgumentError: zone: / strict: / sensitive: / element: …` at class load | 0.13 | [Newly recognized options](#option-names-that-are-now-recognized) |
50
+ | `ArgumentError: rbs_type: must be a non-empty String …` | 0.12 | [`rbs_type:` is recognized](#rbs_type-is-now-a-recognized-option) |
51
+ | `"010"` reads as 10, `"0x1A"` is invalid on an `:integer` | 0.11 | [Integer base 10](#integer-strings-parse-in-base-10) |
52
+ | `"0x1A"` is invalid on a `:float` | 0.13 | [Float hex](#float-refuses-hex-strings) |
53
+ | `round: 0` gives `4.0` / `0.4e1`, not `4` | 0.11 | [`round: 0`](#round-0-keeps-the-fields-type) |
54
+ | `12.345` rounds to `12.35` despite banker's `BigDecimal.mode` | 0.11 | [Rounding mode](#decimal-rounding-ignores-bigdecimalmode) |
55
+ | `NaN` / `Infinity` now invalid; no more `FloatDomainError` | 0.11 | [Non-finite](#nan-and-infinity-are-refused) |
56
+ | `:value` field holds `BigDecimal`; `to_json` writes `"2.25"` | 0.11 | [JSON decimals](#from_json-reads-decimals-as-bigdecimal) |
57
+ | Bundler: `oj` can't resolve | 0.11 | [Oj floor](#oj-must-be--3112) |
58
+ | Message-matching spec fails: `invalid value for Integer()` gone | 0.11 | [Numeric messages](#numeric-messages-name-the-input-class) |
59
+ | Message-matching spec fails: `cannot coerce "…" to boolean`, `no union member could coerce`, `could not coerce "…" for` | 0.13 | [No value echo](#messages-never-echo-the-input-value) |
60
+ | Array error gains `element at index N:` | 0.13 | [Array element index](#array-errors-name-the-element-index) |
61
+ | Array error says `elements at index N, M …` | 0.14 | [Every index](#array-errors-name-every-bad-index) |
62
+ | Mutating a `dup` no longer changes the original's `valid?` | 0.11 | [`dup` errors](#dup-and-clone-own-their-errors) |
63
+ | `RangeError` / `FloatDomainError` no longer raised by `.new` | 0.11 | [RangeError](#rangeerror-is-a-coercion-failure) |
64
+ | Steep: new errors on setters, array elements, unions | 0.12 | [0.12 section](#012--generated-rbs-changes-no-runtime-changes) |
65
+ | Bundler adds `tzinfo` and `concurrent-ruby`, or can't resolve `tzinfo` | 0.14 | [tzinfo dependency](#new-runtime-dependency-tzinfo) |
66
+ | `zone: 'America/Denver'` no longer raises; refusal message reworded | 0.14 | [IANA names](#zone-accepts-iana-names) |
67
+ | An overridden setter is no longer called with `nil` during `new`; a refuse-nil type under `:raise` no longer raises | 0.15 | [Nil default skipped](#new-skips-the-nil-default-for-keys-it-is-given) |
68
+ | `to_json` / `as_json` lose keys an `attributes` override added (or regain ones it dropped) | 0.15 | [`as_json` reads fields](#as_json-reads-the-fields-not-attributes) |
69
+
70
+ ---
71
+
72
+ ## 0.11 — correctness and safer input
73
+
74
+ ### Integer strings parse in base 10
75
+ <!-- upgrading: integer-base-10 -->
76
+
77
+ - **Symptom:** an `:integer` field given `"010"` holds `10` (was `8`); `"0x1A"` /
78
+ `"0b11"` stay raw with `could not be coerced: Integer fields expect a base-10
79
+ integer, got a String that is not one` (were `26` / `3`).
80
+ - **Find:** integer fields, and spec literals with a leading `0` or radix prefix.
81
+ The inputs themselves usually arrive at runtime — no pattern finds those.
82
+ ```sh
83
+ rg -nP --type ruby '(field|required|optional)\s+:\w+,\s*:integer\b'
84
+ rg -nP "['\"][-+]?0(\d+|[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+)['\"]" spec
85
+ ```
86
+ The second pattern matches only whole literals like `"010"`, `"-007"` or `"0x1A"`,
87
+ not `"0.00"` or dates. A hit matters only if the literal reaches an `:integer` field.
88
+ - **Fix:** if the input really is hex (or another radix), say so.
89
+ ```ruby
90
+ required :code, :integer # before: "1A" was refused, "0x1A" read as 26
91
+ ```
92
+ <!-- doctest -->
93
+ ```ruby
94
+ class Payment < FieldStruct::Base
95
+ required :code, :integer, base: 16 # after: "1A" reads as 26
96
+ end
97
+ Payment.new(code: '1A').code # => 26
98
+ ```
99
+ - **Why:** `Kernel#Integer` honoured Ruby literal prefixes, so a zero-padded id became octal.
100
+
101
+ ### `round: 0` keeps the field's type
102
+ <!-- upgrading: round-zero-keeps-type -->
103
+
104
+ - **Symptom:** `:decimal` / `:float` with `round: 0` holds `BigDecimal('4')` /
105
+ `4.0` (was `4`). Specs comparing with `eq(4)` still pass (`4.0 == 4`), but
106
+ `be_a(Integer)`, `.to_s` and JSON output change (`{"total":4.0}`; a `:decimal`
107
+ writes `"4.0"`).
108
+ - **Find:**
109
+ ```sh
110
+ rg -n 'round:\s*0\b'
111
+ ```
112
+ - **Fix:** convert where you need an Integer, or declare the field `:integer`.
113
+ <!-- doctest -->
114
+ ```ruby
115
+ class Order < FieldStruct::Base
116
+ optional :total, :float, round: 0
117
+ end
118
+ order = Order.new(total: 4.4)
119
+ order.total.to_s # => "4.0"
120
+ order.total.to_i # => 4
121
+ ```
122
+ - **Why:** a field always holds its own type.
123
+
124
+ ### `:decimal` rounding ignores `BigDecimal.mode`
125
+ <!-- upgrading: decimal-rounding-ignores-mode -->
126
+
127
+ - **Symptom:** a host that sets `BigDecimal.mode(BigDecimal::ROUND_MODE, :banker)`
128
+ now gets `12.345` → `12.35` at `round: 2` (was `12.34`). Same for `:float`.
129
+ - **Find:**
130
+ ```sh
131
+ rg -n 'BigDecimal\.mode|ROUND_MODE'
132
+ ```
133
+ - **Fix:** if you wanted banker's rounding on a field, drop `round:` and round
134
+ before assigning (`value.round(2, half: :even)`), or wrap it in a custom type.
135
+ - **Why:** a global, process-wide setting changed stored money values.
136
+
137
+ ### NaN and Infinity are refused
138
+ <!-- upgrading: non-finite-refused -->
139
+
140
+ - **Symptom:** `:float` / `:decimal` given `NaN` or `±Infinity` (Float, BigDecimal,
141
+ String or JSON `1e400`) are now invalid —
142
+ `could not be coerced: BigDecimal fields expect a finite number, got a non-finite String`.
143
+ `:integer` used to raise `FloatDomainError` out of `.new` / `from_json`; it now
144
+ records a coercion failure (or raises `FieldStruct::CoercionError` under
145
+ `coercion_policy :raise`). A `:union` no longer stores `"NaN"` in a `:string` member.
146
+ - **Find:**
147
+ ```sh
148
+ rg -n 'NaN|Infinity|FloatDomainError'
149
+ ```
150
+ - **Fix:** represent "unknown" as `nil`, and drop `rescue FloatDomainError` around
151
+ construction — check `valid?` / `errors` instead.
152
+ - **Why:** a non-finite number is not a value any of these fields can hold.
153
+
154
+ ### Numeric messages name the input class
155
+ <!-- upgrading: numeric-messages-name-class -->
156
+
157
+ - **Symptom:** a spec matching `invalid value for Integer(): "abc"` (or `Float()`,
158
+ `BigDecimal()`) fails; the message is now
159
+ `could not be coerced: Integer fields expect a base-10 integer, got a String that is not one`
160
+ (`Float fields could not read a String as a number`, …).
161
+ - **Find:**
162
+ ```sh
163
+ rg -n 'invalid value for (Integer|Float|BigDecimal)'
164
+ ```
165
+ - **Fix:** match the new text, or better, match the stable prefix.
166
+ ```ruby
167
+ expect(rec.errors[:n].first).to match(/invalid value for Integer\(\)/) # before
168
+ expect(rec.errors[:n].first).to start_with('could not be coerced:') # after
169
+ ```
170
+ - **Why:** the old message echoed the raw input into logs.
171
+
172
+ ### `new` and `assign_attributes` refuse non-Hash input
173
+ <!-- upgrading: non-hash-input-refused -->
174
+
175
+ - **Symptom:** `ArgumentError: Person.new expects a Hash (or an object responding to
176
+ #to_hash), got Array`. Raised for an array of pairs (used to work), `new([])`
177
+ (used to build an empty instance) and `new("x")` (was `NoMethodError`).
178
+ `assign_attributes(nil)` raises the same `ArgumentError` (was `NoMethodError`);
179
+ `new(nil)` now builds an empty instance. An *unpermitted*
180
+ `ActionController::Parameters` raises Rails' `UnfilteredParameters`.
181
+ - **Find:** this raises, so a suite that reaches the call finds it. For code it
182
+ doesn't reach, search literal arrays and `nil` passed to your struct classes.
183
+ Add your own base classes (e.g. `ApplicationStruct`) to the first pattern's
184
+ `(…)`; a bare `\.new\(\[` also matches `Array.new` and `Set.new`.
185
+ ```sh
186
+ names=$(rg -oNP --no-filename 'class\s+\K[\w:]+(?=\s*<\s*(FieldStruct::Base|ApplicationStruct)\b)' app lib | sort -u | paste -sd'|' -)
187
+ rg -nP "\b($names)\.(new|assign_attributes)\(\s*(\[|nil\b)" app lib spec
188
+ ```
189
+ - **Fix:**
190
+ ```ruby
191
+ Person.new([[:name, 'Ann']]) # before
192
+ ```
193
+ <!-- doctest -->
194
+ ```ruby
195
+ class Person < FieldStruct::Base
196
+ optional :name, :string
197
+ end
198
+ pairs = [[:name, 'Ann']]
199
+ Person.new(pairs.to_h).name # => "Ann"
200
+ ```
201
+ For Rails params, `permit` them first (`Person.new(params.require(:person).permit(:name))`).
202
+ - **Why:** non-Hash input was silently misread; ActiveModel requires a Hash too.
203
+
204
+ ### Reserved field names
205
+ <!-- upgrading: reserved-field-names -->
206
+
207
+ - **Symptom:** at class load, `ArgumentError: field :errors is reserved:
208
+ FieldStruct::Base relies on #errors, and a field would shadow it. Pick another
209
+ name; serialize :json can keep the external key (e.g. serialize :json,
210
+ errors_field: 'errors').` The full list is `FieldStruct::Base::RESERVED_FIELD_NAMES`
211
+ (includes `errors attributes hash class send to_h inspect as_json raise
212
+ initialize_copy`, and from 0.13 `with dup`). `method`, `name` and `type` stay legal.
213
+ - **Find:** (covers 0.11 and 0.13 names)
214
+ ```sh
215
+ rg -nP --type ruby '(field|required|optional)\s+:(errors|attributes|attribute_names|assign_attributes|as_json|to_json|to_h|to_model|model_name|inspect|hash|class|send|public_send|freeze|instance_variable_set|initialize|initialize_copy|raise|with|dup|deconstruct|deconstruct_keys|json_value|temporal_json_value|apply_defaults|run_cross_field_validators|validate_field|apply_coercion_policy|record_coercion_failure)\b'
216
+ ```
217
+ - **Fix:** rename the field and keep the wire name with a `serialize :json` mapping.
218
+ ```ruby
219
+ class Reply < FieldStruct::Base
220
+ optional :errors, :value # before
221
+ end
222
+ ```
223
+ <!-- doctest -->
224
+ ```ruby
225
+ class Reply < FieldStruct::Base
226
+ optional :errors_field, :value # after
227
+ serialize :json, errors_field: 'errors'
228
+ end
229
+ Reply.from_json('{"errors":["late"]}').errors_field # => ["late"]
230
+ Reply.new(errors_field: ['late']).to_json # => '{"errors":["late"]}'
231
+ ```
232
+ Update readers in calling code (`reply.errors` → `reply.errors_field`).
233
+ - **Why:** these fields broke equality, `inspect` and validation, or crashed the class body.
234
+
235
+ ### `from_json` reads decimals as BigDecimal
236
+ <!-- upgrading: from-json-bigdecimal -->
237
+
238
+ - **Symptom:** after `from_json`, a `:value` field (or a number inside a `:value`
239
+ Hash/Array) holds `BigDecimal` instead of `Float`, and `to_json` writes it as a
240
+ string (`{"v":"2.25"}`, was `{"v":2.25}`). `:decimal` fields now get exact digits.
241
+ JSON `1e400` into `:integer` is `10**400` (was `FloatDomainError`); an `:integer`
242
+ refuses a decimal with more than 4300 integer digits.
243
+ - **Find:** `:value` fields, then the `from_json` paths that feed them.
244
+ ```sh
245
+ rg -nP --type ruby '(field|required|optional)\s+:\w+,\s*:value\b'
246
+ rg -n 'from_json'
247
+ ```
248
+ - **Fix:** give the number a type (`optional :rate, :float`), or convert where you
249
+ read it (`rec.meta['rate'].to_f`). `:float` fields are unchanged.
250
+ - **Why:** parsing through a Float lost digits before a `:decimal` field ever saw them.
251
+
252
+ ### Oj must be >= 3.11.2
253
+ <!-- upgrading: oj-floor -->
254
+
255
+ - **Symptom:** `bundle update field_struct` fails to resolve `oj`.
256
+ - **Find:**
257
+ ```sh
258
+ rg -nw oj Gemfile Gemfile.lock
259
+ ```
260
+ - **Fix:** loosen the pin, e.g. `gem 'oj', '>= 3.11.2'`, then `bundle update oj field_struct`.
261
+ - **Why:** older Oj ignores `decimal_class:`, which the BigDecimal change depends on.
262
+
263
+ ### `base:` is a reserved option name
264
+ <!-- upgrading: base-reserved-option -->
265
+
266
+ - **Symptom:** at class load, `ArgumentError: base: option does not apply to
267
+ String fields. It applies to: Integer.` — for `base:` kept as metadata on a
268
+ non-integer field, and for a custom type that reads `base:` without declaring it.
269
+ - **Find:**
270
+ ```sh
271
+ rg -nP --type ruby '(field|required|optional)\s+:\w+,\s*:(?!integer\b)\w+[^#\n]*\bbase:'
272
+ rg -nP 'def coerce\(.*\bbase:'
273
+ ```
274
+ - **Fix:** in a custom type, declare the option.
275
+ ```ruby
276
+ def self.own_option_schema = {base: option(type: [::Integer])}
277
+ ```
278
+ On a built-in type, rename the metadata key.
279
+ - **Why:** an option some registered type declares is FieldStruct's, and is checked like `round:`.
280
+
281
+ ### `dup` and `clone` own their errors
282
+ <!-- upgrading: dup-own-errors -->
283
+
284
+ - **Symptom:** `b = a.dup; b.name = ''` no longer makes `a.valid?` false. Only code
285
+ that relied on the shared `errors` (unlikely) notices.
286
+ - **Find:** no reliable pattern. It matters only where the receiver is a struct
287
+ instance *and* the code reads the original's `valid?` or `errors` after changing
288
+ the copy. Skip Hash, String and ActiveRecord dups, which are most hits.
289
+ ```sh
290
+ rg -nP '\.(dup|clone)\b' app lib
291
+ ```
292
+ - **Fix:** validate the copy you changed. Copies are still shallow — mutate nested
293
+ structs through `with` (0.13) or by re-assigning.
294
+ - **Why:** a copy that mutates its original's errors was a bug.
295
+
296
+ ### RangeError is a coercion failure
297
+ <!-- upgrading: range-error-policy -->
298
+
299
+ - **Symptom:** `Complex(1, 2)` into `:integer` / `:float`, or a custom type raising
300
+ `RangeError` / `FloatDomainError`, no longer raises out of `.new`; it follows the
301
+ field's `coercion_policy`.
302
+ - **Find:**
303
+ ```sh
304
+ rg -nP 'rescue\s+.*\b(FloatDomainError|RangeError)\b'
305
+ ```
306
+ - **Fix:** check `valid?` / `errors`, or set `coercion_policy :raise` and rescue
307
+ `FieldStruct::CoercionError`.
308
+ - **Why:** every coercion failure goes through one policy.
309
+
310
+ ---
311
+
312
+ ## 0.12 — generated RBS changes, no runtime changes
313
+
314
+ Nothing here changes runtime behaviour except that `rbs_type:` is now a
315
+ recognized option. If you don't generate RBS or run Steep, only the
316
+ [`rbs_type:`](#rbs_type-is-now-a-recognized-option) entry can affect you. Otherwise:
317
+ regenerate, then fix what Steep reports.
318
+
319
+ **Find, for every entry below:** where you generate, and which sigs you keep.
320
+
321
+ ```sh
322
+ rg -n 'FieldStruct::RBS\.generate'
323
+ rg -l 'FieldStruct::Base' sig
324
+ ```
325
+
326
+ ### Setters are typed
327
+ <!-- upgrading: rbs-typed-writers -->
328
+
329
+ - **Symptom:** after regenerating, Steep can report an argument type mismatch on
330
+ `user.age = "30"`: writers are `(::Integer? value) -> ::Integer?` (were
331
+ `(untyped value) -> untyped`). Runtime unchanged — the setter still coerces.
332
+ - **Find:** the generator call above, then `steep check`. No pattern finds loose assignments.
333
+ - **Fix:** assign the coerced type, or pass loose input through a Hash API, whose
334
+ values stay `Object?`.
335
+ ```ruby
336
+ user.age = params[:age] # Steep error after 0.12
337
+ user.assign_attributes(age: params[:age]) # fine: Hash[Symbol, Object?]
338
+ ```
339
+ - **Why:** the sig now says what the reader will return.
340
+
341
+ ### Generated output opens with a header comment
342
+ <!-- upgrading: rbs-header -->
343
+
344
+ - **Symptom:** a committed, generated `.rbs` differs from a fresh run (a staleness
345
+ check fails) only by `# Generated by FieldStruct::RBS. Types describe a valid instance: …`.
346
+ - **Find:** the generator call above.
347
+ - **Fix:** regenerate and commit.
348
+ - **Why:** an invalid instance can hold other values; the header says so.
349
+
350
+ ### Scalar array elements are nullable
351
+ <!-- upgrading: rbs-nullable-array-elements -->
352
+
353
+ - **Symptom:** Steep reports a possible `nil` on `rec.tags.first.upcase`:
354
+ `of: :string` is now `::Array[::String?]` (was `::Array[::String]`). Arrays of
355
+ nested structs stay non-nil.
356
+ - **Find:** hand-written sigs that copied the old shape.
357
+ ```sh
358
+ rg -n '::Array\[::[A-Z]\w*\]' sig
359
+ ```
360
+ - **Fix:** handle the `nil` (`rec.tags.compact`, `&.`), or `rbs_type:` the field
361
+ if your own rule guarantees no nils.
362
+ - **Why:** `["x", nil]` passes validation, so the old sig was false.
363
+
364
+ ### Generic arguments are always present
365
+ <!-- upgrading: rbs-generic-arguments -->
366
+
367
+ - **Symptom:** a custom type whose `ruby_type` is `::Hash` / `::Array` / `::Range`
368
+ now emits `::Hash[untyped, untyped]` (bare `::Hash` failed `rbs validate`).
369
+ - **Find:**
370
+ ```sh
371
+ rg -nU 'def ruby_type[\s=]*(::)?(Hash|Array|Range)\b'
372
+ ```
373
+ - **Fix:** nothing required; to be precise, add the hook to the type.
374
+ ```ruby
375
+ def rbs_type = '::Hash[::Symbol, ::String]'
376
+ ```
377
+ - **Why:** RBS rejects a generic class without its arguments.
378
+
379
+ ### Unions render booleans as `bool`
380
+ <!-- upgrading: rbs-union-bool -->
381
+
382
+ - **Symptom:** `(::String | bool)` replaces `(::String | ::TrueClass | ::FalseClass)`;
383
+ a union with a `:value` member collapses to `untyped`.
384
+ - **Find:**
385
+ ```sh
386
+ rg -n 'TrueClass \| ::FalseClass' sig
387
+ ```
388
+ - **Fix:** regenerate; update hand-written sigs to `bool`.
389
+ - **Why:** shorter and what RBS means by a boolean.
390
+
391
+ ### Optional `:value` fields are `untyped`
392
+ <!-- upgrading: rbs-optional-value-untyped -->
393
+
394
+ - **Symptom:** `untyped?` becomes `untyped` in generated output.
395
+ - **Find:**
396
+ ```sh
397
+ rg -n 'untyped\?' sig
398
+ ```
399
+ - **Fix:** regenerate; `untyped` already admits `nil`.
400
+ - **Why:** `untyped?` is redundant.
401
+
402
+ ### `rbs_type:` is now a recognized option
403
+ <!-- upgrading: rbs-type-option -->
404
+
405
+ - **Symptom:** a field that carried `rbs_type:` as free metadata now raises
406
+ `ArgumentError: rbs_type: must be a non-empty String of RBS (e.g. rbs_type:
407
+ '::ActiveRecord::Relation'), got …` if it isn't a non-blank String, and
408
+ `rbs_type` disappears from `Field#options` (read it as `Field#rbs_type`).
409
+ - **Find:**
410
+ ```sh
411
+ rg -n 'rbs_type:'
412
+ ```
413
+ - **Fix:** pass a String of RBS, or rename your metadata key.
414
+ - **Why:** FieldStruct now emits that type for the field.
415
+
416
+ ### The gem's own signatures admit `nil`
417
+ <!-- upgrading: own-sigs-admit-nil -->
418
+
419
+ - **Symptom:** Steep only. `Base#attributes`, `#to_h`, `#as_json`,
420
+ `#deconstruct_keys` return `::Hash[Symbol, Object?]`, `Field#options` and
421
+ `Metadata#to_h` admit `nil` values, and `**options` are `**Object?`. Code that
422
+ used those values as non-nil gets new Steep errors.
423
+ - **Find:** no reliable pattern; run `steep check`.
424
+ - **Fix:** handle the `nil`, as the runtime always required.
425
+ - **Why:** the old sigs claimed non-nil values the runtime does return as `nil`.
426
+
427
+ ---
428
+
429
+ ## 0.13 — safer input and log safety
430
+
431
+ ### `with` and `dup` are reserved field names
432
+ <!-- upgrading: reserved-with-dup -->
433
+
434
+ - **Symptom:** at class load, `ArgumentError: field :with is reserved:
435
+ FieldStruct::Base relies on #with, and a field would shadow it. …` (same for `:dup`).
436
+ - **Find:**
437
+ ```sh
438
+ rg -nP --type ruby '(field|required|optional)\s+:(with|dup)\b'
439
+ ```
440
+ - **Fix:**
441
+ ```ruby
442
+ optional :with, :string # before
443
+ ```
444
+ <!-- doctest -->
445
+ ```ruby
446
+ class Rule < FieldStruct::Base
447
+ optional :with_field, :string # after
448
+ serialize :json, with_field: 'with'
449
+ end
450
+ Rule.from_json('{"with":"x"}').with_field # => "x"
451
+ ```
452
+ - **Why:** `with` is now a `Base` method and relies on `dup`.
453
+
454
+ ### Array errors name the element index
455
+ <!-- upgrading: array-element-index -->
456
+
457
+ - **Symptom:** an `:array` coercion error gains the element's 0-based index:
458
+ `could not be coerced: element at index 1: Integer fields expect a base-10
459
+ integer, got a String that is not one` (was without `element at index 1: `);
460
+ same in `CoercionError` under `:raise`, which now says `could not coerce Array
461
+ input for ids: …`. `describe` lines for arrays gain `— element accepts …`.
462
+ - **Find:** message and `describe` expectations; no pattern tells array fields apart.
463
+ ```sh
464
+ rg -n 'could not be coerced: ' spec
465
+ rg -nP '(?<!RSpec)\.describe\b' spec
466
+ ```
467
+ - **Fix:**
468
+ ```ruby
469
+ expect(rec.errors[:ids]).to eq(['could not be coerced: Integer fields expect …']) # before
470
+ expect(rec.errors[:ids]).to eq(['could not be coerced: element at index 1: Integer fields expect …']) # after
471
+ ```
472
+ - **Why:** with dozens of elements, the failing one has to be named.
473
+
474
+ ### Messages never echo the input value
475
+ <!-- upgrading: messages-never-echo-values -->
476
+
477
+ - **Symptom:** message-matching specs fail. New shapes:
478
+
479
+ | Where | Before | After |
480
+ |---|---|---|
481
+ | `:boolean` | `cannot coerce "maybe" to boolean` | `Boolean fields expect true, false, 1, 0 or one of the strings "true", "1", "false", "0", got a String that is not one` (non-String: `…, got Hash`) |
482
+ | `:union` | `no union member could coerce "abc"` | `no union member (Integer \| Boolean) could coerce String input` |
483
+ | `coercion_policy :raise` | `could not coerce "abc" for cents: …` | `could not coerce String input for cents: …` |
484
+ | `from_json` non-object root | `…, got Array: ["x"]` | `…, got Array` |
485
+ | `from_json` invalid JSON | Oj's message, quoting the input | `from_json could not parse its input as JSON (unexpected character at line 1, column 7)` — still `EncodingError`, no longer with the Oj error as `cause` |
486
+ | `:integer` / `:float` / `:decimal` | `cause` was Kernel's `invalid value for Integer(): "…"` | no `cause` |
487
+
488
+ - **Find:**
489
+ ```sh
490
+ rg -nP 'cannot coerce .* to boolean|no union member could coerce|could not coerce \S+ for|JSON object root, got \w+: |unexpected character|parse\.c'
491
+ rg -n '\.cause\b'
492
+ ```
493
+ `unexpected character` also matches the new message; check those hits by eye.
494
+ - **Fix:** match on the field and class, not the value.
495
+ ```ruby
496
+ expect { rec }.to raise_error(FieldStruct::CoercionError, /could not coerce "abc" for cents/) # before
497
+ expect { rec }.to raise_error(FieldStruct::CoercionError, /could not coerce String input for cents/) # after
498
+ ```
499
+ If you need the raw value for debugging, read the field (`:keep_raw` keeps it).
500
+ - **Why:** payment data must not reach logs or error reporters.
501
+
502
+ ### `:float` refuses hex strings
503
+ <!-- upgrading: float-refuses-hex -->
504
+
505
+ - **Symptom:** `"0x1A"` on a `:float` stays raw with `could not be coerced: Float
506
+ fields expect a base-10 number, got a String that is not one` (was `26.0`).
507
+ `"017"` is still `17.0`; `"1e3"`, `".5"`, `"1_000.5"` unchanged.
508
+ - **Find:**
509
+ ```sh
510
+ rg -nP --type ruby '(field|required|optional)\s+:\w+,\s*:float\b'
511
+ rg -nP "['\"]\s*[-+]?0[xX]" spec
512
+ ```
513
+ - **Fix:** convert hex before assigning (`Integer(str, 16).to_f`), or use an
514
+ `:integer` field with `base: 16`.
515
+ - **Why:** `:integer` and `:float` both read base 10 now.
516
+
517
+ ### Option names that are now recognized
518
+
519
+ `strict:`, `sensitive:`, `zone:` and `element:` (0.13) — like `base:` (0.11) and
520
+ `rbs_type:` (0.12) — used to pass through onto a field as free metadata. They are
521
+ now checked at declaration.
522
+
523
+ - **Symptom:** at class load, e.g. `ArgumentError: zone: option does not apply to
524
+ String fields. It applies to: Time, DateTime.`, `sensitive: must be true or false,
525
+ got String`, or `zone: on Time expects "UTC" or a UTC offset like "+00:00" or
526
+ "-06:00", got "America/Denver"…`.
527
+ - **Find:**
528
+ ```sh
529
+ rg -nP --type ruby '(field|required|optional)\s+:\w+,[^#\n]*\b(strict|sensitive|zone|element|rbs_type|base):'
530
+ ```
531
+ - **Fix:** rename your metadata key, or adopt the real option ([below](#new-opt-in--worth-adopting)).
532
+ - **Why:** a known option is validated so a typo can't silently do nothing.
533
+
534
+ ## 0.13 → 0.14
535
+
536
+ Fields that declare `"UTC"` or a
537
+ fixed offset behave exactly as in 0.13.
538
+
539
+ ### New runtime dependency: tzinfo
540
+ <!-- upgrading: tzinfo-dependency -->
541
+
542
+ - **Symptom:** `bundle install` adds `tzinfo` (2.0.x) and `concurrent-ruby` to
543
+ `Gemfile.lock` in an app that did not already have them (Rails apps do), so a
544
+ frozen / deployment install refuses the stale lockfile. An app whose lock pins
545
+ `tzinfo` 1.x (Rails 6.0 and older) can't resolve: `Bundler could not find
546
+ compatible versions for gem "tzinfo"`.
547
+ - **Find:**
548
+ ```sh
549
+ rg -n '^\s+tzinfo \(' Gemfile.lock
550
+ ```
551
+ No hit: you gain it. A `1.` version: see Fix.
552
+ - **Fix:** `bundle update field_struct --conservative` and commit the lockfile.
553
+ With tzinfo 1.x pinned by an older Rails, stay on 0.13 until Rails is on 6.1+.
554
+ On a system without OS zoneinfo (Windows, slim containers) also add
555
+ `gem 'tzinfo-data'` — needed only if you declare IANA names, and the declaration
556
+ tells you so.
557
+ - **Why:** `zone: 'America/Denver'` needs a zone database and Ruby's stdlib has
558
+ none. tzinfo is required lazily, only when a field declares a name, so a
559
+ `"UTC"` / `"±HH:MM"` field never loads it.
560
+
561
+ ### `zone:` accepts IANA names
562
+ <!-- upgrading: zone-accepts-iana-names -->
563
+
564
+ - **Symptom:** a spec expecting `ArgumentError` for `zone: 'America/Denver'` fails
565
+ — it now declares fine. A spec matching the refusal message fails; it was
566
+ `zone: on Time expects "UTC" or a UTC offset like "+00:00" or "-06:00", got "MST".
567
+ Zone names such as "America/Denver" are not accepted: …` and is now
568
+ `zone: on Time expects an IANA name like "America/Denver", "UTC" or a UTC offset
569
+ like "-06:00", got "MST". Use an Area/Location name: …`.
570
+ - **Find:**
571
+ ```sh
572
+ rg -n 'expects "UTC" or a UTC offset|no time zone database' spec
573
+ ```
574
+ - **Fix:**
575
+ ```ruby
576
+ expect { decl }.to raise_error(ArgumentError, /expects "UTC" or a UTC offset/) # before
577
+ expect { decl }.to raise_error(ArgumentError, /expects an IANA name like/) # after
578
+ ```
579
+ If you declared a fixed offset because names were refused, consider the name:
580
+ `zone: 'America/Denver'` follows DST where `'-06:00'` does not (see
581
+ [USAGE](USAGE.md#zones-zone) for the gap and overlap rules).
582
+ - **Why:** a fixed offset is wrong for half the year in any DST zone.
583
+
584
+ ### Array errors name every bad index
585
+ <!-- upgrading: array-every-index -->
586
+
587
+ - **Symptom:** with more than one bad element, an `:array` error lists them all:
588
+ `could not be coerced: elements at index 1, 4 (index 1 shown): Integer fields
589
+ expect …` (was `element at index 1: …`), the same in `CoercionError` under
590
+ `:raise`, and `elements at index 2, 3 are invalid` for `element:` validation
591
+ (was `element at index 2 is invalid`). One bad element reads exactly as before.
592
+ - **Find:**
593
+ ```sh
594
+ rg -n 'element at index \d' spec
595
+ ```
596
+ Only hits whose input has two or more bad elements change.
597
+ - **Fix:**
598
+ ```ruby
599
+ expect(rec.errors[:ids]).to eq(['element at index 2 is invalid']) # before
600
+ expect(rec.errors[:ids]).to eq(['elements at index 2, 3 are invalid']) # after
601
+ ```
602
+ Or match the stable part: `/\Aelements? at index /`.
603
+ - **Why:** fixing one element per resubmission is the failure mode this avoids.
604
+
605
+ ### `describe` lists element options by name
606
+ <!-- upgrading: array-describe-names-only -->
607
+
608
+ - **Symptom:** an `:array` field's `describe` line ends `— element accepts in,
609
+ base, strict` instead of `— element accepts in (Array | Range), base (Integer),
610
+ strict (TrueClass | FalseClass)`.
611
+ - **Find:**
612
+ ```sh
613
+ rg -n 'element accepts \w+ \(' spec
614
+ ```
615
+ - **Fix:** drop the shapes from the expectation; read them from the element type
616
+ (`FieldStruct::Types::Integer.option_schema`) if you need them.
617
+ - **Why:** the shapes doubled every array line's length and belong to the element type.
618
+
619
+ ## 0.14 → 0.15
620
+
621
+ This release changes behaviour in two places (both silent); otherwise it is faster, with identical output.
622
+
623
+ | Change | Who is affected | What you see | Why |
624
+ |---|---|---|---|
625
+ | [`new` skips the nil default for keys it is given](#new-skips-the-nil-default-for-keys-it-is-given) | Structs that override a field setter, or use `coercion_policy :raise` with a custom type that refuses `nil` | **Silent:** an overridden setter is no longer called with `nil` before the given value; a refuse-nil type no longer raises from `new` when its key is given | The nil pass was overwritten straight away, at the cost of a setter call per given field |
626
+ | [`as_json` reads the fields, not `attributes`](#as_json-reads-the-fields-not-attributes) | Structs that override `attributes` | **Silent:** `as_json` / `to_json` no longer pick up the override's added, renamed or dropped keys | `as_json` built the whole `attributes` Hash and then walked it again; it now reads each field once |
627
+
628
+ **From 0.14.0:**
629
+ 1. Bump the tag to `v0.15.0`.
630
+ 2. Run the Find in each entry below.
631
+ 3. Run the suite.
632
+
633
+ The other six changes are internal (same output, fewer allocations); no caller acts on them.
634
+
635
+ ### `new` skips the nil default for keys it is given
636
+ <!-- upgrading: defaults-skip-nil-pass -->
637
+
638
+ - **Symptom:** silent. `new` used to give every field its default through the
639
+ setter, then assign the input through the setter again. A field with no
640
+ `default:` whose key the input carries (Symbol or String) now gets only the
641
+ second call. So an overridden setter (`def note=(value)`) sees the given value
642
+ once, not `nil` first. Under `coercion_policy :raise` (class or field level), a
643
+ custom type whose `coerce(nil)` raises no longer raises from `new` when the key
644
+ is given; a key left out still gets the `nil` pass, and still raises. Input
645
+ that is not a Hash (and has no `to_hash`) is now refused before any callable
646
+ `default:` runs. Values, errors, error order and JSON are unchanged, and so is
647
+ instance-variable order unless a setter override never assigns its own ivar
648
+ (that field's `@name` now exists, holding `nil`).
649
+ - **Find:** overridden setters, and `:raise` policies, in your struct classes:
650
+ ```sh
651
+ rg -nP '^\s*def [a-z_]+=\s*\(?' app lib
652
+ rg -n 'coercion_policy:?\s*\(?\s*:raise' app lib
653
+ ```
654
+ Only hits in `FieldStruct::Base` subclasses matter. A setter matters only if
655
+ it acts on `nil` (resets state, counts calls, logs); a `:raise` hit only if a
656
+ field's type is your own and refuses `nil`.
657
+ - **Fix:** usually nothing. If a setter relied on the `nil` call, do that work in
658
+ `initialize` instead:
659
+ ```ruby
660
+ # before: the nil call reset @seen on every new
661
+ def tags=(value)
662
+ @seen = Set.new if value.nil?
663
+ super
664
+ end
665
+
666
+ # after
667
+ def initialize(...)
668
+ @seen = Set.new
669
+ super
670
+ end
671
+ ```
672
+ - **Why:** the `nil` pass was overwritten by the assignment straight after it,
673
+ and cost a full setter call (coercion, validation) per given field.
674
+
675
+ ### `as_json` reads the fields, not `attributes`
676
+ <!-- upgrading: as-json-reads-fields -->
677
+
678
+ - **Symptom:** silent. `as_json` and `to_json` now cover exactly the declared
679
+ fields, each read through its reader, instead of whatever `attributes` returns.
680
+ A struct that overrides `attributes` to add, rename or drop keys no longer sees
681
+ that in its JSON. A reader override (`def note = super&.strip`) still shows, as
682
+ before. `attributes` and `to_h` are unchanged.
683
+ - **Find:** `attributes` overrides in your struct classes:
684
+ ```sh
685
+ rg -nP '^\s*def attributes\b' app lib
686
+ ```
687
+ Only hits in `FieldStruct::Base` subclasses matter.
688
+ - **Fix:** if the JSON should carry the change, override `as_json` too
689
+ (`to_json` goes through it):
690
+ ```ruby
691
+ # before: the extra key reached to_json through attributes
692
+ def attributes
693
+ super.merge(kind: 'payment')
694
+ end
695
+
696
+ # after
697
+ def attributes
698
+ super.merge(kind: 'payment')
699
+ end
700
+
701
+ def as_json(*)
702
+ super.merge(kind: 'payment')
703
+ end
704
+ ```
705
+ - **Why:** `as_json` built the whole `attributes` Hash and then walked it a
706
+ second time; reading each field once drops the intermediate Hash.
707
+
708
+ ---
709
+
710
+ ## New, opt-in — worth adopting
711
+
712
+ None of these change defaults. Each is a one-line change per field.
713
+
714
+ | Option | Since | Use it when | Reference |
715
+ |---|---|---|---|
716
+ | `strict: true` | 0.13 | money and id fields where `12.7` must not become `12`, or a Hash must not become a String | [USAGE](USAGE.md#strict-coercion-strict-true) |
717
+ | `sensitive: true` | 0.13 | card data, tokens, PII — masks the value in `inspect` / `pp` / error suffixes | [USAGE](USAGE.md#log-safety-error-messages-and-sensitive-fields) |
718
+ | `zone: '-06:00'`, `zone: 'America/Denver'` | 0.13, 0.14 | zone-less timestamps that must mean the same instant on every server (a name follows DST) | [USAGE](USAGE.md#zones-zone) |
719
+ | `#with(**changes)` | 0.13 | "updating" an `immutable!` / frozen instance, or re-validating after a nested change | [USAGE](USAGE.md#copying-with-changes-with) |
720
+ | `element: {…}` | 0.13 | per-element options on an array (`of: :integer, element: {strict: true}`) | [USAGE](USAGE.md#element-options-element) |
721
+ | `unknown_attributes :raise` | — | every struct that reads payment or API input (nested classes don't inherit it) | [USAGE](USAGE.md#payment-and-api-boundaries-unknown_attributes-raise) |
722
+ | `rbs_type: '…'` | 0.12 | a `:value` or custom field whose real type FieldStruct can't derive | [USAGE](USAGE.md#rbs-for-your-own-classes) |
723
+ | `base: 16` | 0.11 | an integer that arrives as hex (or any radix 2..36) | [USAGE](USAGE.md#the-numeric-contract) |
724
+
725
+ ## If something else breaks
726
+
727
+ To tell a FieldStruct change from a bug in your code:
728
+
729
+ 1. **Read the message.** FieldStruct coercion errors start with `could not be
730
+ coerced:` (field errors) or `could not coerce <Class> input for <field>:`
731
+ (`CoercionError`), and declaration errors name the field or option. Anything
732
+ else is probably yours.
733
+ 2. **Bisect by version.** Pin the Gemfile to each release tag in turn and rerun the
734
+ one failing spec:
735
+ ```ruby
736
+ gem 'field_struct', git: 'https://github.com/Paymentbox-com/field_struct', tag: 'v0.11.0'
737
+ ```
738
+ `bundle update field_struct --conservative && bundle exec rspec path/to/failing_spec.rb`.
739
+ The first tag where it fails names the section above to read.
740
+ 3. **Ask the model.** `puts Klass.describe` and `pp Klass.metadata.to_h` show what
741
+ each field accepts in the installed version.
742
+ 4. **Still unexplained?** Open an issue with the field declaration, the input's
743
+ class (not the value, if it's sensitive), and the old and new behaviour.