concerns_on_rails 1.32.0 → 1.33.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +417 -0
  3. data/README.md +212 -62
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +22 -2
  5. data/lib/concerns_on_rails/controllers/cacheable.rb +27 -4
  6. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +175 -19
  7. data/lib/concerns_on_rails/controllers/error_handleable.rb +56 -24
  8. data/lib/concerns_on_rails/controllers/filterable.rb +249 -25
  9. data/lib/concerns_on_rails/controllers/localizable.rb +43 -8
  10. data/lib/concerns_on_rails/controllers/paginatable.rb +19 -3
  11. data/lib/concerns_on_rails/controllers/respondable.rb +26 -4
  12. data/lib/concerns_on_rails/controllers/secure_headable.rb +30 -5
  13. data/lib/concerns_on_rails/controllers/sortable.rb +19 -2
  14. data/lib/concerns_on_rails/controllers/throttleable.rb +20 -1
  15. data/lib/concerns_on_rails/controllers/timezoneable.rb +45 -11
  16. data/lib/concerns_on_rails/models/activatable.rb +41 -5
  17. data/lib/concerns_on_rails/models/addressable.rb +116 -20
  18. data/lib/concerns_on_rails/models/anonymizable.rb +31 -4
  19. data/lib/concerns_on_rails/models/auditable.rb +38 -26
  20. data/lib/concerns_on_rails/models/counter_cacheable.rb +174 -50
  21. data/lib/concerns_on_rails/models/duplicable.rb +235 -17
  22. data/lib/concerns_on_rails/models/encryptable.rb +324 -44
  23. data/lib/concerns_on_rails/models/expirable.rb +84 -16
  24. data/lib/concerns_on_rails/models/hashable.rb +6 -1
  25. data/lib/concerns_on_rails/models/lockable.rb +13 -2
  26. data/lib/concerns_on_rails/models/maskable.rb +15 -2
  27. data/lib/concerns_on_rails/models/monetizable.rb +30 -3
  28. data/lib/concerns_on_rails/models/normalizable.rb +83 -15
  29. data/lib/concerns_on_rails/models/publishable.rb +77 -19
  30. data/lib/concerns_on_rails/models/sanitizable.rb +89 -5
  31. data/lib/concerns_on_rails/models/schedulable.rb +22 -6
  32. data/lib/concerns_on_rails/models/searchable.rb +12 -4
  33. data/lib/concerns_on_rails/models/sequenceable.rb +126 -23
  34. data/lib/concerns_on_rails/models/sluggable.rb +79 -10
  35. data/lib/concerns_on_rails/models/soft_deletable.rb +231 -27
  36. data/lib/concerns_on_rails/models/sortable.rb +64 -0
  37. data/lib/concerns_on_rails/models/stateable.rb +100 -21
  38. data/lib/concerns_on_rails/models/storable.rb +26 -7
  39. data/lib/concerns_on_rails/models/taggable.rb +51 -2
  40. data/lib/concerns_on_rails/models/tokenizable.rb +12 -1
  41. data/lib/concerns_on_rails/support/address_data.rb +76 -4
  42. data/lib/concerns_on_rails/support/affix.rb +69 -0
  43. data/lib/concerns_on_rails/support/association_scope.rb +21 -0
  44. data/lib/concerns_on_rails/support/batch_ops.rb +68 -16
  45. data/lib/concerns_on_rails/support/contextual_destroy.rb +317 -0
  46. data/lib/concerns_on_rails/support/encryptor.rb +11 -1
  47. data/lib/concerns_on_rails/support/generated_values.rb +95 -14
  48. data/lib/concerns_on_rails/support/hooked_write.rb +1 -1
  49. data/lib/concerns_on_rails/support/html_sanitizers.rb +19 -6
  50. data/lib/concerns_on_rails/support/link_header.rb +13 -1
  51. data/lib/concerns_on_rails/support/locking.rb +53 -6
  52. data/lib/concerns_on_rails/support/masker.rb +12 -4
  53. data/lib/concerns_on_rails/support/money.rb +59 -8
  54. data/lib/concerns_on_rails/support/numeric_operand.rb +5 -2
  55. data/lib/concerns_on_rails/support/primary_key.rb +28 -0
  56. data/lib/concerns_on_rails/support/scalar_param.rb +28 -7
  57. data/lib/concerns_on_rails/support/sequence_calculator.rb +37 -8
  58. data/lib/concerns_on_rails/support/time_value.rb +42 -0
  59. data/lib/concerns_on_rails/support/transform_backstop.rb +70 -0
  60. data/lib/concerns_on_rails/support/write_boundary.rb +64 -0
  61. data/lib/concerns_on_rails/version.rb +1 -1
  62. data/lib/concerns_on_rails.rb +4 -0
  63. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5638a56a20ce9b346dd013448af31656fe6925eaefbf8bd87ee8906c17825b4
4
- data.tar.gz: b3feeb0feaab7f14bf5d03ea46a9e7a1a69f0d087173cf2fb0baa1efb2c86daa
3
+ metadata.gz: 0dd550c226626fbbe5d8c542b5e139296be95977722dd1e78560f81ed91c63b1
4
+ data.tar.gz: f8770b8fe33e57b5323a76a469e8cd2cac66401a6e20a3b12a800b99776c80f4
5
5
  SHA512:
6
- metadata.gz: 25623c6d25c7fafc8e8c4dfd5f16350df059a5f0bb2999c0d457b986cfcff1c55365f8e072ab5dd01cb9ae48e6226ff335a546f02c1521ca0c3934898e92c296
7
- data.tar.gz: ad5dd6aa2c310c9d711fd9513e01488e79f164df54aa2accdbca8ab9a46f3c2fe296c0733c6b0c404c0df09147c28160f7c5177e0938b6637eb3037d71f1e363
6
+ metadata.gz: aeec3000adc45a218468a6be90ed35ed8022f9319f3c41a222dcdeb0e048eb95f634c5e9d4a4613ad0cbdde7558d9e24d94f132c6c664622fd48393e26ff78ad
7
+ data.tar.gz: eca60a294934ecb578df643c92108a0b86ed62c1f0bd5f63c376ae12691d51ce3e6e7bce37438c896f9872c228de077950982a9c3ae53f674f83a4b10fa1ba22
data/CHANGELOG.md CHANGED
@@ -1,5 +1,422 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.33.0 (2026-10-18)
4
+
5
+ A correctness release: it fixes the 66 bugs proven by the 2026-10 audit of 1.32.1. Some fixes change
6
+ behaviour, and several configurations that used to be silently broken now raise at class load. Read
7
+ **Upgrading** first.
8
+
9
+ ### Upgrading
10
+
11
+ **Class-load (boot) raises for configurations that were silently broken.** Each of these raised nothing
12
+ before and misbehaved at run time; an app that boots today may fail to boot after upgrading.
13
+
14
+ - [ ] **Encryptable, uniqueness:** a uniqueness validation over an encrypted field (or an encrypted `scope:`
15
+ column) raises `ArgumentError`. It always passed and saved duplicates. Use `where_<field>` with a unique
16
+ index, or `blind_index: true`. (#187)
17
+ - [ ] **Encryptable, `serialize`/`normalizes`:** declared on the field before `encryptable` raises
18
+ (`serialize` on Rails 6.1+, `normalizes` on 7.1+). Declare `encryptable` first. (#188)
19
+ - [ ] **Encryptable, DB default:** a non-empty database default on an encrypted column raises. Drop it with the
20
+ `change_column_default` named in the message (only `NULL` or `""` are allowed). (#136)
21
+ - [ ] **Encryptable, scope columns:** an `encryptable` column as a Sequenceable `scope:`, a Sluggable/friendly_id
22
+ scope column or a Sortable `scope:` raises, in either order. Scope by an unencrypted column (a foreign key, a
23
+ plaintext copy). (#189)
24
+ - [ ] **Monetizable, marks:** `separator:` equal to `delimiter:` (the usual slip is `separator: ","` alone, since
25
+ the default delimiter is also `","`), or empty with `precision:` above 0, raises, at the macro and for
26
+ per-call `formatted_` overrides. A `precision: 0` field with `delimiter: "."` (IDR / VND / CLP) now raises
27
+ too: pass `separator: ","`. (#158)
28
+ - [ ] **Addressable, `default_country:`:** a value that resolves to no ISO country raises; a valid name or alpha-3
29
+ is stored as its alpha-2 code. Fix typos and unknown values. (#174)
30
+ - [ ] **Shared affixed names:** two concerns that generate the same affixed predicate or scope name (for example
31
+ `prefix: :term` on Expirable and Activatable) raise at the macro. Give them distinct affixes. (#184)
32
+ - [ ] **Sortable (controller):** `direction:` other than `asc`/`desc` (any case) raises (`:dsc`, `nil`). `"DESC"`
33
+ used to sort ascending. (#165)
34
+ - [ ] **Sequenceable, `period_column:`:** the macro refuses a missing, non-date or `:updated_at` column. Under `reset:` it also refuses a subclass that restates its parent's visible format with a different `period_column:`, because the two series would print the same numbers. Declare the option on the declaring class, or in an `assign:`-only re-declaration. (#191)
35
+
36
+ **Behaviour changes.**
37
+
38
+ - [ ] **SoftDeletable `destroy_all`:** on a relation or association it is now a contextual soft delete
39
+ (`where(...).destroy_all`, `destroy_by`, `in_batches.destroy_all`, `post.comments.destroy_all`); only the
40
+ bare class form did before, and the scoped forms hard-deleted. Live rows are soft-deleted through
41
+ `soft_delete!`. Already-trashed rows are purged for real only when reached through that model's own trash
42
+ scopes (`only_deleted`, `soft_deleted`, `with_deleted`, `deleted_within`), fixed at call start; every other
43
+ relation leaves the trash alone. A purge also purges the record's trashed `cascade:` children, of any stamp,
44
+ ignoring the association's limit; a vetoed child skips its parent on a relation but raises on an
45
+ association. A restored row is re-checked before its purge. Rails' own `has_many :through` maintenance stays
46
+ a hard delete. Escape hatches: `really_destroy_all` to hard-delete on purpose, or `soft_deletable_by ...,
47
+ relation_destroy_all: :hard` for the old behaviour of the scoped forms. Destroy callbacks no longer run for
48
+ live rows (`before/after_soft_delete` run instead), and `touch: true` runs validations and update callbacks;
49
+ a failing row raises `RecordNotSaved` and rolls the batch back. (#169)
50
+ - [ ] **Batch verbs on STI tables** (`publish_all`/`unpublish_all`, `soft_delete_all`/`restore_all`, `expire_all`,
51
+ `activate_all`/`deactivate_all`, `unlock_expired`) always stream per record, so subclass hooks and validations
52
+ run; slower on large tables. `self.inheritance_column = nil` keeps the single `UPDATE`. (#185)
53
+ - [ ] **Batch verbs with sibling bookkeeping:** on a model with a CounterCacheable rule using `if:`, or Auditable
54
+ tracking a column the verb writes, they take the per-record path instead of the single `UPDATE`. (#154)
55
+ - [ ] **Publishable/Activatable:** `publish!` on a published record, `activate!` on an active one and
56
+ `deactivate!` on an inactive one are no-ops (return `true`, no stamp, no hooks). Use `publish_at!` to re-stamp. (#146)
57
+ - [ ] **Expirable:** `expire!`/`expire_all` never push an expiry later. Use `expire_in!` or `extend_expiry!` to set a
58
+ later one. (#141)
59
+ - [ ] **Authorizable:** the denial envelope `code` follows `status:`: `"unauthorized"` for 401, `"not_found"` for
60
+ 404, `"forbidden"` otherwise. Clients matching on `forbidden` need updating. (#145)
61
+ - [ ] **Respondable:** a `Vary` in `headers:` is appended to the existing `Vary` instead of replacing it; a
62
+ caller's `Vary: *` still replaces. (#160)
63
+ - [ ] **ErrorHandleable:** a malformed query string renders the JSON 400 `bad_request` envelope instead of Rails'
64
+ HTML 400 page. `handle_errors except: :bad_request` restores propagation. (#155)
65
+ - [ ] **Encryptable/Auditable:** the blind index and the audit trail are captured at the write boundary, after
66
+ every `before_*` callback, so your own callbacks see them as loaded; the new values are in place from
67
+ `after_save`. (#172)
68
+ - [ ] **Anonymizable:** `Model.anonymize_all!` also erases SoftDeletable's trash and Publishable's unpublished rows
69
+ under `default_scope: true`. Call it on a relation to limit it. (#171)
70
+ - [ ] **Storable:** a key with `type:` reads its `default:` cast through that type (uncastable reads `nil`); keys
71
+ without `type:` and `:json` defaults return the default as written. (#130)
72
+ - [ ] **Filterable:** enum comparisons use labels (#163) and an unknown enum label matches nothing (#168);
73
+ `type: :datetime` reads a zone-less operand in `Time.zone` or `ActiveRecord.default_timezone`, no longer the
74
+ server's system zone (#170); an operand with a NUL byte matches nothing (#152).
75
+ - [ ] **Searchable:** `ranked:` search on a `DISTINCT` relation is returned unranked; order it yourself. (#135)
76
+ - [ ] **Sluggable:** clearing the slug source keeps the existing slug instead of a uuid. (#151)
77
+ - [ ] **Monetizable:** with `precision:` omitted and a power-of-ten `subunit_to_unit:` of 1000 or more, output
78
+ prints more decimals (3 for `1000`, 4 for `10_000`). Pass `precision:` to pin it. (#180)
79
+ - [ ] **Normalizable:** `:url` turns `//HOST/path` into `https://host/path`, and a host-less `/path` is returned
80
+ stripped as `/path`. (#177)
81
+ - [ ] **Timezoneable:** `Time.zone` is always restored after the action, also for the current zone. (#192)
82
+ - [ ] **Duplicable:** a copy of a Sortable record takes a fresh list position instead of the original's. (#166)
83
+ - [ ] **Sanitizable `on: :write`:** a persisted field the save does not change is no longer re-sanitized;
84
+ `sanitize_all!` remains the repair tool. (#148)
85
+ - [ ] **CursorPaginatable:** a numeric cursor boundary the column cannot hold is an `InvalidCursor` 400, not an
86
+ empty page (#182); a cursor minted for a `time` column is now the stored time of day, old cursors still
87
+ decode on Rails 7.0+ (#183); binary boundaries travel as Base64 (#162). Cursors minted before the upgrade
88
+ are not all safe: on Rails 6.0/6.1 an old `time` cursor repeats its boundary row once (#183), and an old
89
+ cursor on an ASCII-only binary column may be reinterpreted once (#162). The next cursor is in the new form.
90
+
91
+ ### Added
92
+ - **Sequenceable**: `period_column:` (default `:created_at`) names the datetime column a `reset:` period is cut
93
+ from. With `assign: :manual`, `period_column: :finalized_at` numbers a draft in the period it is finalized in
94
+ instead of the one it was drafted in; `assign_<field>!` stamps the column when blank. The MAX range, the
95
+ period token and `sequenceable_period_time` all read it. (#191)
96
+ - **SoftDeletable**: `soft_deletable_by ..., relation_destroy_all: :hard` restores ActiveRecord's own hard
97
+ delete for the relation and association forms of `destroy_all`. (#169)
98
+
99
+ ### Changed
100
+ - **Addressable**: `default_country:` is canonicalized like a country value (name, alpha-3 or alpha-2) and
101
+ stored as its alpha-2 code. A value that resolves to no ISO country raises `ArgumentError` when the macro
102
+ runs; `default_country: "USA"` or `"Canada"` used to silently disable postal, state and fingerprint matching
103
+ for every blank-country record. (#174)
104
+ - **Authorizable**: the denial's envelope `code` follows its `status:`: `"unauthorized"` for 401, `"not_found"`
105
+ for 404, `"forbidden"` for 403 and anything else (it was always `"forbidden"`). A `status: :not_found`
106
+ concealment rule now renders exactly what ErrorHandleable's missing-record 404 renders. The
107
+ `authorization_denied(status:, message:)` override signature is unchanged. (#145)
108
+ - **Anonymizable**: `Model.anonymize_all!` called on the model itself also erases the rows the gem's own default
109
+ scopes hide: SoftDeletable's trash and Publishable's unpublished rows under `default_scope: true`. The
110
+ application's own default scopes (a tenant, a discriminator) still apply. Called on a relation, an
111
+ association or inside `scoping`, it still erases exactly that relation. (#171)
112
+ - **Batch verbs (Publishable / SoftDeletable / Expirable / Activatable / Lockable)**: on an STI table (a
113
+ present `inheritance_column` that is a real column) `publish_all`/`unpublish_all`,
114
+ `soft_delete_all`/`restore_all`, `expire_all`, `activate_all`/`deactivate_all` and `unlock_expired` always
115
+ stream per record instead of one `UPDATE`, so a subclass's overridden hooks, validations and re-declared
116
+ options apply. A model with `self.inheritance_column = nil` keeps the single `UPDATE`. (#185)
117
+ - **Concerns sharing affixed names**: declaring two concerns that generate the same affixed predicate or scope
118
+ name (for example `prefix: :term` on Expirable and Activatable) raises `ArgumentError` at the macro. Before,
119
+ the second silently replaced the first's `term_active?` and `.term_active`. Unaffixed concerns that share plain
120
+ names are unchanged. (#184)
121
+ - **Encryptable**: a non-empty database default on an encrypted column is refused (`ArgumentError` at
122
+ declaration, with the `change_column_default` that drops it). Any default other than `NULL` or `""` is
123
+ plaintext that can never decrypt, so every `Model.new` raised `DecryptionError`. Skipped while the schema is
124
+ unreachable. (#136)
125
+ - **Encryptable**: a uniqueness validation over an encrypted field (or an encrypted `scope:` column), declared
126
+ before or after `encryptable`, raises `ArgumentError` at class load. Before, it always passed and duplicates
127
+ were saved. Uniqueness on plain columns is unchanged. (#187)
128
+ - **Encryptable**: `serialize` or `normalizes` declared on the same field before `encryptable` raises
129
+ `ArgumentError` at class load (Rails 6.1+ for `serialize`, 7.1+ for `normalizes`). Declare `encryptable` first.
130
+ Before, `encryptable` silently dropped the earlier decoration. Rails 6.0 serialize-first declarations are
131
+ unchanged. (#188)
132
+ - **Encryptable / Sequenceable / Sluggable / Sortable**: an `encryptable` column can no longer be a Sequenceable
133
+ `scope:`, a Sluggable/friendly_id scope column (an association's foreign key and multi-column scopes
134
+ included) or a Sortable (acts_as_list) `scope:`; declaring one raises `ArgumentError` from either order.
135
+ Scoped queries compared ciphertext and matched no row. Scope by an unencrypted column instead. (#189)
136
+ - **CursorPaginatable**: a numeric cursor boundary the column cannot hold (beyond its precision or range, finer
137
+ than its scale, non-numeric or not valid UTF-8, over 100 characters, or with an exponent beyond +-1000) is an
138
+ `InvalidCursor`, which renders the documented 400. Before, it silently ended the walk with an empty 200. (#182)
139
+ - **Expirable**: expiring never pushes an existing expiry later. `expire_all(time)` writes only rows whose expiry
140
+ is unset or later than `time`, and the count is the rows actually written. `expire!(time)` on a record whose
141
+ saved expiry is already at or before `time` returns `true`, writes nothing and fires no hook. To set a later
142
+ expiry, use `expire_in!` or `extend_expiry!`. (#141)
143
+ - **Filterable**: `type: :datetime` reads a zone-less operand exactly as the target column would: in `Time.zone`
144
+ on a zone-aware column, in `ActiveRecord.default_timezone` otherwise. A `with:` lambda under `type: :datetime`
145
+ receives the value in `Time.zone` while `time_zone_aware_attributes` is on, else in `default_timezone`.
146
+ `type: :date` / `:time` use ActiveRecord's own types. Results used to depend on the server's `ENV["TZ"]`. (#170)
147
+ - **Monetizable**: raises `ArgumentError` when `separator:` equals `delimiter:` or is empty while `precision:` is
148
+ above 0, at the macro and for per-call `formatted_` overrides. `formatted_price` used to print `"EUR1,234,56"`,
149
+ which the writer read back as `nil`. This includes `precision: 0` fields whose marks are equal (IDR / VND /
150
+ CLP with `delimiter: "."`); pass `separator: ","`. (#158)
151
+ - **Monetizable**: with `precision:` omitted it follows a power-of-ten `subunit_to_unit:`, with at least 2
152
+ decimals: 3 for `1000` (KWD, BHD, OMR, TND, JOD), 4 for `10_000`, so `formatted_price` round-trips. Output for
153
+ `100`, `10`, `1` and non-power-of-ten subunits is unchanged, an explicit `precision:` is used as given, and a
154
+ per-call `subunit_to_unit:` override gets its own default. (#180)
155
+ - **Publishable / Activatable**: `publish!` on a published record, `activate!` on an active one and
156
+ `deactivate!` on an inactive one are no-ops: they return `true`, stamp nothing and fire no hook. "Already"
157
+ means the saved state of a stored record with no unsaved change to the column; a no-op that finds other
158
+ pending changes saves them. `publish_at!` still re-stamps, `deactivate!` still writes `false` over `NULL`, and
159
+ `toggle_active!` is unchanged. (#146)
160
+ - **Sanitizable** `on: :write` no longer re-runs the writer on a persisted record's field that the save does
161
+ not change, so a non-idempotent `Proc` writer no longer compounds on every save. An unrelated save no longer
162
+ "repairs" a row written around the callbacks; `sanitize_all!` remains the repair tool. (#148)
163
+ - **Searchable**: `ranked:` search on a `DISTINCT` relation is returned unranked, like a grouped relation,
164
+ instead of ordering by a rank expression outside the SELECT list (rejected by PostgreSQL and MySQL 5.7.5+).
165
+ The relation's own ORDER BY is kept. (#135)
166
+ - **Sluggable**: clearing the slug source (`nil`, a blank string, or a value that normalizes to nothing such as
167
+ `"!!!"`) keeps the existing slug instead of replacing it with friendly_id's uuid fallback. The fallback still
168
+ applies to a record with no slug yet. (#151)
169
+ - **SoftDeletable**: `destroy_all` is a contextual soft delete on a relation and on an association too, as
170
+ documented (`Model.where(...).destroy_all`, `Model.destroy_by(...)`, `in_batches.destroy_all`,
171
+ `post.comments.destroy_all`). The scoped forms used to reach ActiveRecord's own `destroy_all` and hard-delete.
172
+ A live record is soft-deleted through `soft_delete!` (hooks and `cascade:` run; `RecordNotSaved` and rollback
173
+ on failure). A record already soft-deleted is purged only when reached through that model's own trash scopes
174
+ (`only_deleted` / `soft_deleted` / `with_deleted` / `deleted_within`), fixed at call start; any other
175
+ relation leaves the trash alone. A purge also purges the record's trashed `cascade:` children, of any stamp,
176
+ ignoring the association's limit. A vetoed child skips its parent on a relation but raises on an association
177
+ (`RecordNotDestroyed`). A restored row is re-checked before its purge. Rails' `has_many :through`
178
+ maintenance stays a hard delete, as do `dependent: :destroy`, `assoc.destroy(c)`, `replace` and nested
179
+ `_destroy`; instance `destroy` is unchanged. The relation and association forms return the records acted on
180
+ (the class form still returns the count). The `relation_destroy_all: :hard` option restores the old behaviour.
181
+ See **Upgrading**. (#169)
182
+ - **Sortable** (controller): `sortable_by`'s `direction:` is case-insensitive, and any other value (`:dsc`, `nil`)
183
+ raises `ArgumentError` at class load, as CursorPaginatable does. It used to be silently coerced to `:asc`. (#165)
184
+
185
+ ### Fixed
186
+ - **Addressable**: `normalize_country:` recognizes `"UK"`, `"Great Britain"`, `"United States of America"`, ISO's
187
+ official short names (`"Viet Nam"`, `"Russian Federation"`, `"Republic of Korea"`), former names (`"Czech
188
+ Republic"`) and accent-free spellings; `default_country:` (see Changed) uses the same lookup. (#174, #175)
189
+ - **Addressable**: stamps the `fingerprint:` column in a prepended `validate` callback (after every
190
+ `before_validation`, ahead of the app's own validations) as well as in `before_save`, so a uniqueness
191
+ validation on the column no longer accepts duplicates or judges an update against the old address. (#173)
192
+ - **Addressable**: `same_address_as?` accepts a digest String as well as a record, matching `with_address`. (#181)
193
+ - **Auditable** (with **Encryptable**): changes are captured at the write boundary, after every `before_*`
194
+ callback, so the trail records the values actually written; a field set or rewritten by a later callback is no
195
+ longer missing or stale, and a change a callback reverted no longer leaves a phantom entry. An audited
196
+ blind-index column records the refreshed digest. (#172)
197
+ - **Batch verbs** (`expire_all`, `activate_all`, `deactivate_all`, `unpublish_all`, SoftDeletable's): select their
198
+ rows by their own predicates, so a sibling concern owning the public `.active` or `.published` name can no
199
+ longer make `expire_all` overwrite long-expired rows and skip live ones. (#184)
200
+ - **Batch verbs** (Publishable / Expirable / Activatable): take their per-record path on a model with a
201
+ CounterCacheable rule using `if:`, or Auditable tracking a column the verb writes; the single-`UPDATE` fast
202
+ path used to leave the counter and the audit trail behind. Plain models keep the single `UPDATE`. (#154)
203
+ - **Cacheable**: `stale_resource?` on an Array (or other non-relation collection) no longer answers 500 on Rails
204
+ 7.0+ when a member has no `updated_at` or a nil one. Only an `ActiveRecord::Relation` takes the SQL-aggregate
205
+ branch; other collections use their newest non-nil member timestamp (none: ETag only). (#132)
206
+ - **CounterCacheable**: a parent with optimistic locking whose counted child hangs off a `has_one ...,
207
+ dependent: :destroy` can be destroyed again; the child's decrement no longer bumps the parent's
208
+ `lock_version`. A has_one replacement still gets its count. (#149)
209
+ - **CursorPaginatable**: a cursor whose String boundary is not valid UTF-8, or carries a NUL byte, is the
210
+ documented `InvalidCursor` 400 instead of a 500. MySQL adapters, which store a NUL, still accept it. (#144)
211
+ - **CursorPaginatable**: paginates tables with a binary primary key or a binary ordering column; a binary
212
+ boundary travels in the cursor as strict Base64 (invalid Base64 is an `InvalidCursor` 400). `JSON.generate`
213
+ used to raise on every page with a next page. (#162)
214
+ - **CursorPaginatable**: a `time` ordering column with fractional seconds no longer loops forever on Rails
215
+ 6.0/6.1. A `time` boundary is now its stored time of day (`"10:30:15.500000"`, in `default_timezone`); cursors
216
+ minted in the old form still decode on Rails 7.0+. (#183)
217
+ - **Duplicable**: `duplicate!` re-reads `lock_version` with the counter columns, for the copy and every nested
218
+ Duplicable copy, so the returned copy of an optimistically locked record saves again. (#143)
219
+ - **Duplicable**: a top-level copy of a Sortable (acts_as_list) record takes a fresh list position, placed per
220
+ `add_new_at:`, instead of the original's. A deep-copied child keeps its position only when its list is scoped
221
+ by the copied association's foreign key; a child in a shared list is blanked and appended. `use_acts_as_list:
222
+ false` keeps the copied position. (#166)
223
+ - **Encryptable / Anonymizable / Stateable / Activatable**: composite primary keys (Rails 7.1+) work in
224
+ `reencrypt!` / `reencrypt_all!`, in `anonymize!` under optimistic locking, and in the row lock behind Stateable
225
+ `lock: true` and Activatable `toggle_active!`. (#138)
226
+ - **Encryptable**: `<field>_ciphertext`, `<field>_encrypted?`, `<field>_key_id` and `reencrypt!` read the envelope
227
+ the record loaded or wrote, also after an assignment that leaves the value unchanged. (#150)
228
+ - **Encryptable**: an in-place edit of a decrypted String (`<<`, `gsub!`, `squish!`) is a change and is saved;
229
+ dirty checks decrypt the stored value to compare plaintext, so an unchanged re-save is still not re-encrypted. (#156)
230
+ - **Encryptable**: the blind index is refreshed at the write boundary, after every `before_save` /
231
+ `before_create` / `before_update` callback, so it is the digest of the value actually stored and `find_by_<field>`
232
+ no longer misses a row whose field a later callback rewrote. (#172)
233
+ - **ErrorHandleable**: a malformed query string (bad %-escape, invalid UTF-8) renders the documented 400
234
+ `bad_request` envelope (or problem document) instead of Rails' HTML 400 page. A private `process_action` hands
235
+ that one exception to the handler chain. (#155)
236
+ - **ErrorHandleable**: `handled_error.concerns_on_rails` and `on_handled_error` report the exception the handler
237
+ was matched on (the wrapper's `#cause`), not the wrapper's class. (#193)
238
+ - **Expirable / Schedulable / Publishable**: on a `date` column the instance predicates (`current?`,
239
+ `upcoming?`, `expired?`, `active_at?`, `overlaps?`, `active?`, `published?`, `scheduled?`) agree with their
240
+ scopes outside UTC, comparing against the date in `Time.zone`. Datetime columns are untouched. (#140)
241
+ - **Filterable**: compares an `enum` column by label (`?status_gte=review` is `status >= 1`); on an integer-backed
242
+ enum any other operand is read as a number. Every label comparison used to come back empty, and a numeric
243
+ operand raised `ArgumentError` on Rails 6.0/6.1. An unknown label matches nothing. (#163)
244
+ - **Filterable**: an unknown `enum` label in equality or `in` (`?status=drfat`) matches nothing instead of the
245
+ rows with no status (Rails 7.0+ emitted `IS NULL`). An explicit `nil` member keeps its `IS NULL` meaning. (#168)
246
+ - **Filterable**: an operand containing a NUL byte matches nothing instead of a `StatementInvalid` 500 (SQLite,
247
+ PostgreSQL). Binary columns are exempt. (#152)
248
+ - **Filterable / Sortable** (controller): `filtered` and `sorted` accept a bare model class (`filtered(Article)`),
249
+ as Paginatable and CursorPaginatable already did; a class used to raise `NoMethodError`. (#164)
250
+ - **Localizable**: the `Accept-Language` lookup truncates each tag one subtag at a time (RFC 4647 section 3.4), so
251
+ `zh-Hant-TW` matches an available `zh-Hant` before `zh`, and `sr-Latn-RS` matches `:sr_Latn` before `:sr`. (#167)
252
+ - **Maskable / Sanitizable**: `masked_attributes` / `sanitized_attributes` skip a declared field that a partial
253
+ `select` did not load instead of raising `ActiveModel::MissingAttributeError`. (#178)
254
+ - **Monetizable**: no longer reads a leading `0` digit group as grouped thousands: `"0,500"` in a default
255
+ `","` field is `nil` rather than 500.00, and `"0.500"` reads as 0.5 in a `delimiter: "."` field. Formatted
256
+ output still round-trips. (#179)
257
+ - **Normalizable / Sanitizable / Taggable / Addressable**: a rule no longer runs twice when `valid?` precedes
258
+ `save` (a non-idempotent Proc runs once per save, also when Normalizable and a Sanitizable `on: :write` rule share
259
+ a column), and `reload` forgets the earlier validation. The `before_save` backstops (saves that skip validation)
260
+ run in the same order as the `before_validation` passes through one shared prepended dispatcher, so two
261
+ transforms on one column store the same value on every save path. (#176)
262
+ - **Normalizable**: `:url` canonicalizes a protocol-relative `//HOST/path` to `https://host/path`; a host-less
263
+ `/path` comes back stripped rather than as `https:///path`. (#177)
264
+ - **Paginatable**: counts a relation whose select list is written with DISTINCT (`select("DISTINCT category")`,
265
+ `DISTINCT ON (...)`) as its distinct rows, fixing `X-Total-Count` / `X-Total-Pages` and `Link rel="last"`. (#161)
266
+ - **Publishable**: `unpublish!` and `unpublish_all` write `false` into a boolean publish column instead of
267
+ `NULL`, which raised `NotNullViolation` on `null: false` columns. Timestamp columns are still cleared. (#139)
268
+ - **Respondable**: a `Vary` passed in `render_success` / `render_created`'s `headers:` is appended to the existing
269
+ `Vary` (de-duplicated case-insensitively, `Vary: *` left alone) instead of replacing it, which dropped
270
+ Localizable's `Accept-Language` and Timezoneable's `Time-Zone`. A caller's `Vary: *` still replaces. (#160)
271
+ - **Sanitizable**: no longer raises `NameError: uninitialized constant Rails::HTML4` on rails-html-sanitizer
272
+ older than 1.6. The sanitizer namespace is feature-detected: HTML5 when available, else HTML4, else the gem's
273
+ `Rails::Html` sanitizers. (#133)
274
+ - **SecureHeadable**: `content_security_policy_for(report_only: false)` enforces over a parent controller's or
275
+ `config.content_security_policy_report_only`'s report-only flag. `report_only:` is now `true` / `false` /
276
+ `:inherit`; the default `:inherit` leaves the inherited flag as before, and anything else raises
277
+ `ArgumentError`. (#137)
278
+ - **Sequenceable / Duplicable**: a duplicated record is numbered in its own `reset:` period (the copy used to
279
+ inherit the original's memoized "now"); Duplicable blanks a custom `period_column:`. (#191)
280
+ - **Sequenceable / Tokenizable / Hashable / Sortable**: a number, token, code or list position generated on create
281
+ no longer survives a failed INSERT; a create that rolls back puts the generated columns back, so
282
+ `UniqueRetry.with_retries { record.save! }` draws a fresh value. Values you supplied are kept. (#190)
283
+ - **Sluggable**: an explicitly assigned blank slug (`""` or `nil`) is built from the source in the same save
284
+ instead of stored as is; a non-blank assigned slug still wins. (#131)
285
+ - **Sluggable**: `scope: [:account_id, :locale]` (friendly_id's multi-column scope) no longer raises
286
+ `NoMethodError` at class load; every scope column is validated and slugs are unique per combination. (#157)
287
+ - **SoftDeletable**: `really_delete!` marks the instance destroyed (`destroyed?` true, `persisted?` false)
288
+ before freezing it. (#134)
289
+ - **SoftDeletable / Publishable**: merging a soft-deletable model's relation into another model's no longer strips
290
+ the OUTER model's own `deleted_at IS NULL`. The default scope records no `unscope`, and the named scopes unscope
291
+ the column on their own table only (Rails 6.1+; on 6.0 a merged named scope still leaks). (#186)
292
+ - **Stateable**: `transition_all` on a `lock: true` model checks each guard under its row lock, so a row another
293
+ process moved is skipped instead of aborting the whole batch with `InvalidTransition`. (#147)
294
+ - **Stateable**: a base-class `transition_all` on an STI table judges each record by its own class's declaration:
295
+ subclass rows without the event are skipped (no `NoMethodError`) and rows a subclass's wider `from:` accepts
296
+ are transitioned. New public reader `stateable_transition_config(event)`. (#153)
297
+ - **Storable**: a key that declares `type:` reads its `default:` back cast through that type (`default: "false"`
298
+ on a `:boolean` key used to read the truthy String). An uncastable default reads `nil`; `:json` defaults and
299
+ untyped keys return the default as written. (#130)
300
+ - **Timezoneable**: `Time.zone` is restored after every action, also when the resolved zone is already the
301
+ current one; a `Time.zone = ...` made in the action no longer leaks to the next request. (#192)
302
+ - **Tokenizable**: `authenticate_by_<field>` / `consume_<field>` answer `nil` for a value that is not a String or
303
+ Integer (a Hash from `?token[a]=b`, an Array) instead of raising `TypeError`. (#142)
304
+
305
+ ### Internal
306
+ - Dev lock: permittable bumped to 0.11. (#159)
307
+ - New `Support::TransformBackstop`: one shared prepended `before_save` dispatcher for Normalizable, Sanitizable,
308
+ Taggable and Addressable. (#176)
309
+ - New `Support::ScalarParam.nul_free?` (NUL-byte operand check used by Filterable). (#152)
310
+
311
+ ## 1.32.1 (2026-10-08)
312
+
313
+ A bug-fix release from a six-angle audit of 1.32.0 — data and encryption, state and counters,
314
+ scopes and batch verbs, field transforms, request params, HTTP policy. Every finding was
315
+ reproduced by a failing spec before it was fixed, and every fix ships with a regression spec;
316
+ the batch then went through an adversarial review round. No new concerns, no new options and
317
+ no migrations. A few fixes now raise where the gem used to misbehave silently — read
318
+ **Changed** first. 2827 examples, 0 failures (2795 on Rails 6.0).
319
+
320
+ ### Changed
321
+ - **Encryptable: Lockable's `unlock_token:` cannot be `encryptable`** (`ArgumentError` at
322
+ declaration, either order). The token is minted, claimed and cleared by callback-skipping
323
+ SQL keyed on the column value, so an encrypted token was never found: every unlock link was
324
+ dead. Keep it a plain column — it is random, single-use and cleared with the lock.
325
+ - **Monetizable: an `encryptable` cents column is refused** (`ArgumentError`, either order):
326
+ `sum_`/`average_`/`minimum_`/`maximum_` ran SQL aggregates over ciphertext.
327
+ - **Auditable: `audited_changes_since` reads its argument in `Time.zone`** — a `Date` is
328
+ midnight in `Time.zone`, a zone-less String is read in `Time.zone` (not the server's system
329
+ zone), and a String that names no time raises `ArgumentError` (`"junk"` used to mean June 1).
330
+ - **Encryptable: blind indexes refresh in `before_create`/`before_update`**, after every
331
+ `before_save`, instead of in an include-time `before_save`. An app `before_save` that rewrites
332
+ the field (`self.email = email.downcase`) is now fingerprinted as stored, and a new row always
333
+ refreshes every indexed field, so a copy (`dup`, Duplicable's `reset:`) no longer keeps its
334
+ original's digest. Rows written by such a callback before this release keep a stale digest
335
+ until re-saved with a change.
336
+
337
+ ### Fixed
338
+ - **Encryptable**: decrypted text reads back as UTF-8 (binary only when the bytes are not valid
339
+ UTF-8). Non-ASCII values compared unequal to what was written after a reload, were dirty on
340
+ every reassignment, and `reencrypt!` fingerprinted different bytes under a `downcase`
341
+ `expression:`, so rotated rows could no longer be found.
342
+ - **Encryptable**: an empty-String column default (`text null: false, default: ""`) reads as
343
+ `""` instead of raising `DecryptionError` on every new record.
344
+ - **Encryptable**: `find_by_<field>` / `where_<field>` / `<field>_fingerprint` run the lookup
345
+ value through the field's Normalizable rule and its Rails 7.1+ `normalizes`, as Rails' own
346
+ finders do.
347
+ - **Storable**: a `:float` key given `"Infinity"`/`"NaN"` casts to nil instead of raising
348
+ `JSON::GeneratorError` (a 500 from a form field).
349
+ - **SoftDeletable**: `without_deleted.really_destroy_all` (and any relation repeating the
350
+ default `deleted_at IS NULL`) no longer hard-deletes the trash, and `without_deleted.restore_all`
351
+ no longer restores it: only the default scope's own predicate is peeled off.
352
+ - **`*_all` batch verbs** (`Support::BatchOps`) roll the whole batch back inside a caller's
353
+ transaction too: the batch runs in its own savepoint, so a caller rescuing the
354
+ `RecordNotSaved` no longer commits the records written before the failing one.
355
+ - **Sluggable**: a record moved to another `scope:` value whose slug that scope already holds
356
+ rebuilds it against the new scope instead of duplicating it; a slug that is free there is kept.
357
+ - **Sortable**: the position acts_as_list assigns in `before_create` is reported through
358
+ `Support::GeneratedValues`, so an Auditable model tracking it records it on create.
359
+ - **CounterCacheable**: a save that changes a row's STI type (`becomes!`, `update!(type:)`)
360
+ judges the old state under the old type's rule and the new state under the new type's, and
361
+ destroy uses the persisted type's rule — the live counter no longer drifts from
362
+ `recount_counter_caches!`.
363
+ - **Stateable `lock: true` / Activatable `toggle_active!`**: the reload under the row lock keeps
364
+ a loaded `belongs_to` parent whose foreign key it didn't change, so CounterCacheable's mirror
365
+ reaches the caller's parent instead of leaving it to raise `StaleObjectError`.
366
+ - **Stateable**: `transition_all` no longer skips rows already in the target state when the
367
+ event's `from:` lists that state (a declared self-transition).
368
+ - **Lockable**: `attempts_remaining` returns the full `max_attempts` once a lock has lapsed
369
+ (`unlock_in`), instead of 0 on an unlocked account.
370
+ - **Sanitizable** `on: :write`, **Taggable** and **Addressable** also normalize in a prepended
371
+ `before_save` backstop (Normalizable's pattern), so `update_attribute` / `save(validate: false)`
372
+ no longer store raw markup, un-normalized tags or un-normalized address parts. A validated
373
+ save stores exactly what it stored before (the backstop never re-runs a rule on a value
374
+ validation already accepted).
375
+ - **Maskable / Sanitizable**: `as_json(masked: true)` / `as_json(sanitized: true)` carry the flag
376
+ through every nested `include:` level, so a grandchild reached through a model without the
377
+ concern is no longer serialized raw.
378
+ - **Masker `:email`** fails closed (full mask) unless the value is exactly one address, so a
379
+ second address in a list is no longer revealed.
380
+ - **Monetizable**: a `Rational` amount converts (`Rational(1999, 100)` → 1999 cents) instead of
381
+ being stored as nil.
382
+ - **Searchable / Taggable**: queries and tags are trimmed (and `mode: :all` split) on Unicode
383
+ whitespace — a no-break or ideographic space no longer becomes part of the LIKE pattern or a
384
+ distinct tag.
385
+ - **Filterable**: a nested-array param (`?name[][]=a&name[][]=b`) is ignored instead of raising
386
+ a `TypeError` 500; a JSON-body number on a date/datetime column — compared, in equality,
387
+ `in`/`not_in`, or handed to a `type: :date` lambda — matches nothing instead of being bound raw
388
+ (a 500 on PostgreSQL); a date/time operand longer than 128 characters no longer raises
389
+ `Date._parse`'s `ArgumentError` (a 500) on Rails 6.0–7.0 and is answered as 7.1+ answers it.
390
+ - **Paginatable**: a number where a nested page Hash belongs (`{"page": 2}` under
391
+ `style: :jsonapi`) falls back to the defaults instead of a `TypeError` 500.
392
+ - **CursorPaginatable**: a page ending on a stored ±Infinity/NaN float no longer raises
393
+ `JSON::GeneratorError` on every request — the boundary is minted as a string and walked
394
+ correctly on PostgreSQL and SQLite; a tampered number/boolean for a date/time boundary, or an
395
+ over-long date String on Rails 6.0–7.0, is an `InvalidCursor` (400), not a decoded position
396
+ or a 500.
397
+ - **Paginatable / CursorPaginatable**: `Link` header URLs are percent-encoded inside `<…>`, so a
398
+ crafted request path can no longer inject extra Link entries.
399
+ - **Throttleable**: the first-hit seed is written `raw: true` (to any store whose `write` takes
400
+ options), so a memcached `Rails.cache` on Rails 6.0–7.0 enforces the limit again (every later
401
+ `increment` returned nil and the 429 never fired).
402
+ - **Timezoneable**: a `Time-Zone` header or `time_zone` cookie that is not valid UTF-8 is ignored
403
+ instead of raising a 500 (persistently, for the cookie); the `available:` allow-list matches by
404
+ zone, not spelling, so `America/New_York` matches an allow-listed `Eastern Time (US & Canada)`.
405
+ - **Cacheable**: per-call `stale_resource?(r, extras: [...])` downgrades `Cache-Control` to
406
+ `private`, like a Vary-less `etag_with` source, so a CDN no longer serves one caller's
407
+ representation to the next.
408
+ - **Localizable**: `Accept-Language: pt-BR` (and `?locale=pt-br`) selects an available `:pt_BR`;
409
+ `-` and `_` are one separator (an exact spelling still wins).
410
+
411
+ ### Known issues (not fixed in this release)
412
+ - CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
413
+ mirrored counter/lock_version in memory.
414
+ - Aliasable: eager-loading an alias together with its source in one query loads each child twice.
415
+ - Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
416
+ cannot be `Marshal.dump`ed (`Rails.cache`).
417
+ - CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
418
+ its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
419
+
3
420
  ## 1.32.0 (2026-10-07)
4
421
 
5
422
  A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs