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/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.
|