concerns_on_rails 1.29.0 → 1.31.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 +470 -0
  3. data/README.md +242 -70
  4. data/lib/concerns_on_rails/configuration.rb +2 -1
  5. data/lib/concerns_on_rails/controllers/cacheable.rb +44 -10
  6. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +264 -55
  7. data/lib/concerns_on_rails/controllers/deprecatable.rb +7 -3
  8. data/lib/concerns_on_rails/controllers/filterable.rb +212 -10
  9. data/lib/concerns_on_rails/controllers/idempotentable.rb +105 -19
  10. data/lib/concerns_on_rails/controllers/localizable.rb +42 -6
  11. data/lib/concerns_on_rails/controllers/paginatable.rb +131 -14
  12. data/lib/concerns_on_rails/controllers/permittable.rb +1 -0
  13. data/lib/concerns_on_rails/controllers/throttleable.rb +30 -5
  14. data/lib/concerns_on_rails/controllers/timezoneable.rb +26 -1
  15. data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +92 -31
  16. data/lib/concerns_on_rails/core.rb +93 -0
  17. data/lib/concerns_on_rails/models/activatable.rb +54 -15
  18. data/lib/concerns_on_rails/models/addressable.rb +83 -13
  19. data/lib/concerns_on_rails/models/aliasable.rb +37 -0
  20. data/lib/concerns_on_rails/models/anonymizable.rb +362 -32
  21. data/lib/concerns_on_rails/models/auditable.rb +101 -23
  22. data/lib/concerns_on_rails/models/counter_cacheable.rb +493 -46
  23. data/lib/concerns_on_rails/models/duplicable.rb +251 -59
  24. data/lib/concerns_on_rails/models/encryptable.rb +418 -47
  25. data/lib/concerns_on_rails/models/expirable.rb +81 -25
  26. data/lib/concerns_on_rails/models/hashable.rb +63 -6
  27. data/lib/concerns_on_rails/models/lockable.rb +181 -30
  28. data/lib/concerns_on_rails/models/monetizable.rb +15 -18
  29. data/lib/concerns_on_rails/models/normalizable.rb +63 -8
  30. data/lib/concerns_on_rails/models/publishable.rb +89 -22
  31. data/lib/concerns_on_rails/models/sanitizable.rb +17 -4
  32. data/lib/concerns_on_rails/models/schedulable.rb +73 -16
  33. data/lib/concerns_on_rails/models/searchable.rb +25 -2
  34. data/lib/concerns_on_rails/models/sequenceable.rb +241 -22
  35. data/lib/concerns_on_rails/models/sluggable.rb +161 -7
  36. data/lib/concerns_on_rails/models/soft_deletable.rb +60 -28
  37. data/lib/concerns_on_rails/models/sortable.rb +1 -0
  38. data/lib/concerns_on_rails/models/stateable.rb +334 -33
  39. data/lib/concerns_on_rails/models/storable.rb +87 -42
  40. data/lib/concerns_on_rails/models/taggable.rb +57 -3
  41. data/lib/concerns_on_rails/models/tokenizable.rb +56 -8
  42. data/lib/concerns_on_rails/railtie.rb +41 -3
  43. data/lib/concerns_on_rails/support/address_data.rb +1 -2
  44. data/lib/concerns_on_rails/support/affix.rb +187 -3
  45. data/lib/concerns_on_rails/support/association_scope.rb +121 -0
  46. data/lib/concerns_on_rails/support/batch_ops.rb +87 -2
  47. data/lib/concerns_on_rails/support/callable.rb +70 -0
  48. data/lib/concerns_on_rails/support/encrypted_lookup.rb +53 -0
  49. data/lib/concerns_on_rails/support/generated_values.rb +63 -0
  50. data/lib/concerns_on_rails/support/hooked_write.rb +164 -0
  51. data/lib/concerns_on_rails/support/html_sanitizers.rb +66 -0
  52. data/lib/concerns_on_rails/support/locking.rb +126 -0
  53. data/lib/concerns_on_rails/support/masker.rb +44 -12
  54. data/lib/concerns_on_rails/support/money.rb +135 -2
  55. data/lib/concerns_on_rails/support/numeric_operand.rb +207 -0
  56. data/lib/concerns_on_rails/support/scalar_param.rb +30 -2
  57. data/lib/concerns_on_rails/support/sequence_calculator.rb +92 -5
  58. data/lib/concerns_on_rails/support/slug_sources.rb +60 -0
  59. data/lib/concerns_on_rails/support/time_value.rb +306 -0
  60. data/lib/concerns_on_rails/support/unique_retry.rb +12 -2
  61. data/lib/concerns_on_rails/version.rb +1 -1
  62. data/lib/concerns_on_rails.rb +16 -74
  63. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f00644495f4a20c3265cfa9289708136b4bb086aebeeb16026ebd2c9e12f7991
4
- data.tar.gz: 01162f76ade27fb2b42aa93c3317819fb4db96c761717fdc20cafeedd213046f
3
+ metadata.gz: 493ce0d913905625ffaabf033052a0ccc631aa22fe3c9a06a04c776ad4a80f67
4
+ data.tar.gz: 022bb7a0230e1c9c8ebc210c43245cf458c9248f3810f24f02c731695c0c046d
5
5
  SHA512:
6
- metadata.gz: fad1b9c385054e869d5dcd4012f1e06182b3597c63a500d6513521fbefd758480ffcaf85922c0d805a2d1b07430f03a3d0a3fb1184f510450adfabd311155630
7
- data.tar.gz: e7a299357dc9f389a4e9e99241af15c783806a96dc4b9037fe5283d1737038b63f53224a3c6e77e5f5b0a926e3d341df435c8c7b0d319a73258b33a489a15614
6
+ metadata.gz: 55cd86cfb3b04af3972060c2d4455dca7fe2ee7abfdccfa029c75ee6a9caee4b6700eeb128dd3f15aec5c52460af7ca5936935185593788d02df9c050044e3fa
7
+ data.tar.gz: 750dda120eb24d9e3d121a8e4ec1828e2d26303677f5758bc415f138fa4497845e1b67219203dc593ad5fe55a1a406e77d544b292f1639d4aa56546bb855010a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,475 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.31.0 (2026-10-05)
4
+
5
+ The fixes from two audits, each fix put through adversarial review rounds until a reviewer
6
+ signed it off: the 2026-09-25 audit of 1.30.0 (#116–#120) and the 2026-09-30 audit of this
7
+ release's own candidate (#123–#125: values generated in `before_create`, optimistic locking
8
+ around the gem's raw writes, and time values), plus dependency refreshes (#121, #126). It is a
9
+ minor release because Sequenceable gains an option and a reader, and several fixes change
10
+ observable behaviour or raise where the gem used to silently misbehave. Read **Changed** before
11
+ upgrading: blind indexes of generated encrypted values, and of `:datetime` fields, want a
12
+ one-off backfill. No new concerns and no migrations. Every fix ships with a regression spec.
13
+ 2723 examples, 0 failures (2694 on Rails 6.0), green on PostgreSQL and MySQL.
14
+
15
+ ### Changed
16
+ - **Sequenceable: `reset:` periods are cut in one fixed zone**, not the per-request
17
+ `Time.zone`. The `MAX` range and the period token both use the new `time_zone:` (default:
18
+ `config.time_zone`, resolved at use time, else UTC). Before, two requests in different zones
19
+ (Timezoneable, `Time.use_zone`) could issue the **same number**, and `formatted_<field>`
20
+ without `into:` rendered a different date for readers in different zones. Apps that keep
21
+ `Time.zone == config.time_zone` see no change. With `into:` and `reset:` (no `template:`) the
22
+ next number also accounts for rows whose stored value already carries the period's `prefix +
23
+ token + separator`, so a number stamped under a request zone before the upgrade is continued,
24
+ never reissued; without `into:`, add a unique index before upgrading. (#116)
25
+ - **Sequenceable: re-declaration.** A call passing **only `assign:` and/or `time_zone:`** keeps
26
+ the rest of the field's current config, so `sequenceable_by :sequence, assign: :manual` on a
27
+ `Draft` subclass keeps the parent's `into:`/`prefix:`/`reset:`/`template:`. Every other
28
+ re-declaration, a bare one included, restates the format from the defaults as before. A
29
+ re-declaration whose full format tuple is identical to an inherited owner's now shares that
30
+ owner's counter, instead of numbering a subclass-only series that could reissue its numbers
31
+ (Procs compare by identity, so a draft subclass should re-declare only `assign:`).
32
+ **Upgrading:** a subclass declared the `assign:`-only way used to number its own
33
+ default-format series; its rows without an `into:` value now render in the parent's format,
34
+ so backfill `into:` first. (#116)
35
+ - **Sequenceable: classes sharing a counter under `reset:` must cut the same periods.** Both
36
+ `time_zone:` omitted, or both explicit and the same zone (compared by canonical TZInfo
37
+ identifier, then by UTC offsets 1970–2100, so `"Kolkata"`/`"Asia/Calcutta"` match), else
38
+ `ArgumentError` at class load. Declare `time_zone:` on the class that first declares a
39
+ format, and declare an owner fully before its subclasses. (#116)
40
+ - **CursorPaginatable: nullable ordering columns paginate, NULLs last** in both directions on
41
+ every adapter (PostgreSQL `NULLS LAST`, a `CASE` sort key elsewhere; the keyset WHERE uses
42
+ the OR-expansion). NULL-ordered rows used to be silently dropped, and NULLs sorted first
43
+ under PostgreSQL DESC and SQLite/MySQL ASC. NOT NULL columns keep the same SQL. An ordering
44
+ column the relation did not select raises `ActiveModel::MissingAttributeError`, a stored
45
+ non-NULL value that casts to nil raises `ArgumentError`, and ordering columns must be
46
+ selected unaliased. (#117)
47
+ - **CursorPaginatable**: re-declaring `cursor_paginate_by` (e.g. in a subclass) changes only
48
+ the options passed and inherits the rest, so a subclass setting only `order:` no longer
49
+ silently disables the parent's `signed:`. (#117)
50
+ - **Paginatable**: a `DISTINCT` relation with its own `SELECT` list
51
+ (`select(:group_id).distinct`) is counted as a subquery of the relation's own SQL, so
52
+ `X-Total-Count` / `X-Total-Pages` count the distinct rows returned, not every underlying row.
53
+ Only MySQL, which rejects repeated output names in a derived table (error 1060, recognised by
54
+ driver code), retries with the select list re-aliased, and falls back to the previous
55
+ over-count if that fails too. (#117)
56
+ - **Stateable: name collisions raise.** `stateable_by` raises `ArgumentError` when a generated
57
+ `<state>?`/`<state>!`/`<event>!`/`may_<event>?` method or `.<state>` scope would override a
58
+ method the class already has from ActiveRecord or another concern — an event `lock`
59
+ overriding AR's `lock!` (which broke `with_lock` and `lock: true`), an event `restore` next
60
+ to SoftDeletable, a state `active` next to Activatable. Use `prefix:`/`suffix:`. The reverse
61
+ order raises too. One exemption: a state may take a scope that is still SoftDeletable's,
62
+ Publishable's or Schedulable's untouched include-time default on the same class, so that
63
+ concern's own macro can rename it — include that concern before `stateable_by`; only its
64
+ affixing macro may come after. An unrenamed collision raises when the shared scope is called.
65
+ (#120)
66
+ - **Stateable**: a re-declaration retires the methods and scopes the earlier declaration
67
+ generated and the new one no longer lists (a subclass with `states: %i[open closed]` no
68
+ longer answers its parent's `archive!` or `.draft`). Only methods Stateable itself defined
69
+ are retired; a user's own method under such a name is left alone. The attribute default no
70
+ longer forces a `:string` type onto the state column (a PG enum/citext or host-declared
71
+ `attribute` type is kept, per declaring class), and a re-declaration without `default:` keeps
72
+ the earlier default only while it is still a declared state. (#120)
73
+ - **Publishable**: `default_scope:` is a flag read by one lazily registered default scope,
74
+ added only the first time a class passes `default_scope: true`, so a model that never asks
75
+ for it keeps Rails' statement cache. An explicit `default_scope: false` on a later call or an
76
+ STI subclass turns it off; omitting the option keeps the inherited setting. (#120)
77
+ - **Encryptable**: an encrypted field used as a friendly_id slug source raises `ArgumentError`
78
+ — the `sluggable_by` field, a `candidates:` entry, an `alias_attribute` of the field (chains
79
+ followed) or a bare friendly_id base — at declaration where it can, otherwise at save, before
80
+ the row is written. The slug used to store the field's plaintext. **Upgrading:** regenerate
81
+ those slugs from a safe field and delete the stale `friendly_id_slugs` rows (see the
82
+ Encryptable docs). (#118)
83
+ - **SoftDeletable `cascade:` / Duplicable** now reach children hidden by Publishable's
84
+ `default_scope: true` (drafts). Only the gem's own hiding predicates on the child's own table
85
+ are removed; every other default-scope predicate still applies, as for Rails' `dependent:` —
86
+ a tenant scope, a discriminator on a shared table, a joined table's same-named column. A
87
+ `has_one` cascades to / copies the child its reader returns. Duplicable never copies a
88
+ SoftDeletable child's soft-deleted rows into a `has_many`/`has_one` copy, whatever its
89
+ `default_scope:` (with it off, the copy used to get them back as live rows). (#119)
90
+ - **Normalizable**: saves that skip validation (`update_attribute`, `save(validate: false)`)
91
+ are normalized by a prepended `before_save` backstop, so Encryptable's blind index, Auditable
92
+ and Addressable see the normalized value whatever the include order. (#120)
93
+ - **CounterCacheable**: re-declaring the same association + `count:` column replaces the
94
+ earlier rule (it double-counted); an STI subclass can narrow an inherited counter with `if:`;
95
+ on an STI table `recount_counter_caches!` tallies every class's rows under its own rule,
96
+ resolving each stored type without building a record (an unknown type counts under the base
97
+ rule instead of raising). (#120)
98
+
99
+ - **Upgrading (Encryptable + Tokenizable/Hashable/Sequenceable):** rows created before this
100
+ release with a generated, blind-indexed encrypted value (a token, code or number) were stored
101
+ with a **NULL** blind index. They stay unfindable by `find_by_<field>` / `where_<field>` /
102
+ `authenticate_by_<field>` / `consume_<field>` until backfilled. `reencrypt_all!` does not
103
+ reach them, because they are already under the current key. Run once per field:
104
+ `Model.unscoped.where(<field>_bidx: nil).where.not(<field>: nil).find_each { |r|
105
+ r.update_columns(<field>_bidx: Model.<field>_fingerprint(r.<field>)) }` (no callbacks, any
106
+ key). See `docs/concerns/encryptable.md#upgrading-blind-indexes-of-generated-values`. (#123)
107
+ - **Searchable:** `searchable_by` over an `encryptable` column (or `encryptable` over a searched
108
+ one) raises `ArgumentError` at declaration. Before, `search` ran `LIKE` against ciphertext and
109
+ silently returned nothing. (#123)
110
+ - **Taggable:** `taggable_by` over an `encryptable` column (or `encryptable` over the tag
111
+ column) raises `ArgumentError` at declaration. A model that includes Taggable without
112
+ `taggable_by` and encrypts its default `:tags` is refused when `tagged_with` is called. New
113
+ `taggable_declared` class attribute. (#123)
114
+ - **Tokenizable:** on an `encryptable :<field>, blind_index: true` token,
115
+ `authenticate_by_<field>` / `consume_<field>` look the token up through the blind index (still
116
+ `secure_compare`d), and `consume_<field>` clears the index along with the token. Encrypted
117
+ without a blind index, they raise `ArgumentError` when called; before, they returned `nil` for
118
+ every token. Generating, storing and rotating an encrypted token work as before. (#123)
119
+ - **Anonymizable:** with `lock_version`, `anonymize!` / `anonymize_all!` write their single
120
+ UPDATE through `update_all`, bumping `lock_version` in SQL (the row's value + 1). An edit form
121
+ loaded before the erasure now raises `StaleObjectError` instead of writing the personal data
122
+ back. A stale anonymizing instance now erases; on Rails 7.0+ its constrained `update_columns`
123
+ used to erase nothing while returning `true`. `update_columns`' refusals (destroyed,
124
+ `readonly!`, `attr_readonly`) are kept. (#124)
125
+ - **Expirable:** `extend_expiry!` extends the **stored** expiry, read under a row lock, so
126
+ concurrent extensions compound (two `+30.days` renewals give +60, not +30). An expiry assigned
127
+ but not yet saved, and a new record, still extend from the instance's value. With optimistic
128
+ locking, a stale instance raises `StaleObjectError` rather than losing a renewal. (#124)
129
+ - **Encryptable:** `type: :datetime` behaves like a datetime column on the same model. Under
130
+ `time_zone_aware_attributes`, a zone-less String (a `datetime-local` form value) and a
131
+ `datetime_select` value are wall-clock time in `Time.zone`, a `Date` is midnight there, and
132
+ the field reads back as an `ActiveSupport::TimeWithZone`. Otherwise it follows
133
+ `ActiveRecord.default_timezone`. It is exactly the handling Rails gives `attribute :name,
134
+ :datetime` (ActiveRecord wraps the type in its own `TimeZoneConverter`), so `normalizes` and a
135
+ parent's re-declaration are inherited by subclasses.
136
+ `skip_time_zone_conversion_for_attributes` (Symbols) is honored when set before the
137
+ `encryptable` line; on Rails 6.0–7.1 wherever it is set. From Rails 7.2, where Rails decides
138
+ at declaration, a skip list set later or on a subclass raises `ArgumentError` when the model
139
+ is first used, unless the field is re-declared right after it. The plaintext stays UTC
140
+ ISO8601, so existing rows read as the same instant. A stored plaintext without `Z` or an
141
+ offset is read in `default_timezone`, never the server's zone. (#125)
142
+ - **Encryptable:** a blind index fingerprints the field's canonical plaintext (the value cast
143
+ through its `type:`, a `:datetime` as UTC ISO8601) on write and on lookup. `find_by_<field>` /
144
+ `where_<field>` on a `:datetime` field find the record by any rendering of the instant,
145
+ whatever `Time.zone` the writer, the reader or `reencrypt!` ran in, and a String finds a typed
146
+ field (`find_by_age("042")`). `expression:` receives the cast value (a `:datetime` as a UTC
147
+ `Time`). Lookups also try the digest the index used before, so existing rows stay findable by
148
+ the lookup that found them. `:datetime` fields should be reindexed once (recipe in
149
+ `docs/concerns/encryptable.md#blind-index-of-a-typed-field`, which skips rows it cannot
150
+ decrypt); every other type's digest is unchanged. A non-numeric String on an `:integer` field
151
+ finds nothing, as an integer column's `where`, and a value that does not cast
152
+ (`Float::INFINITY`) is stored as `NULL` with no digest. (#125)
153
+ - **Storable:** `:datetime` keys behave like a datetime column on the same model, with the same
154
+ `Time.zone` / `default_timezone` rules as Encryptable. The reader returns an
155
+ `ActiveSupport::TimeWithZone` under `time_zone_aware_attributes`, `where_<key>` parses its
156
+ argument the same way as the writer, a `datetime_select` value is wall-clock time in
157
+ `Time.zone`, and a `default:` (a String, a `Time` or a Proc) reads back as the same value
158
+ stored would. List the accessor in `skip_time_zone_conversion_for_attributes` to opt a key
159
+ out. The stored UTC ISO8601 form is unchanged. (#125)
160
+ - **Publishable / SoftDeletable / Schedulable / Expirable:** `publish_at!`, `soft_delete!(at:)`,
161
+ `start!`, `finish!`, `reschedule!`, `expire!` and `expire_all` raise `ArgumentError` before
162
+ any hook runs when given a value that is not a time: a String must read as a date the way
163
+ `Time.zone.parse` will read it: a four-digit year, no ordinal day, every part in range, years
164
+ 0001–9999, and only date words or a time zone (ISO 8601, RFC 2822 with its comments, HTTP
165
+ dates, JavaScript `Date#toString`, Go and RFC 9557 forms, `"Oct 1 2026"`, `"2026-10-01 10:30
166
+ EST"` and `"10:30 p.m."` all pass; `"junk"`, `"Monday"`, `"10:30"`, `"junk 2026"`, `"Oct 1
167
+ 26"`, `"15th of October 2026"` and `"7:30p"` do not, although `Time.zone.parse` reads them as
168
+ June 1st, today, year 26 AD, October 1st or military zone P), and `42` or `1.hour` never cast.
169
+ The first five used to write `NULL` and still return `true`; Expirable accepted the yearless
170
+ words in time-zone-aware apps. Column assignment is unchanged. The check is the shared
171
+ `Support::TimeValue.cast_argument!`. (#125)
172
+ - **SoftDeletable:** `soft_delete!(at: nil)` or a blank `at:` means now, as for `expire!`.
173
+ `soft_delete!(at: params[:at])` with the param absent used to write `NULL`, fire the hooks and
174
+ return `true` with nothing deleted. `publish_at!(nil)` (and blank) still writes `NULL` and
175
+ fires the publish hooks. (#125)
176
+
177
+ ### Added
178
+ - **Sequenceable**: `time_zone:` (a zone name as a String or Symbol, or an
179
+ `ActiveSupport::TimeZone`; an unknown zone raises at class load) and
180
+ `sequenceable_period_time(field)`, the fixed-zone period instant for `template:` lambdas that
181
+ render a date. (#116)
182
+
183
+ ### Fixed
184
+ - **Sequenceable**: re-declaring a field with `assign: :manual`, on the same class or an STI
185
+ subclass, really stops numbering at create. Each call used to add its own `before_create`
186
+ lambda, and the earlier `:create` one kept firing. Each field now has one symbol callback,
187
+ registered at its first `assign: :create` declaration, honouring the receiving class's
188
+ current declaration. (#116)
189
+ - **Paginatable**: `paginated(Model)` / `pagination_meta(Model)` with a bare model class no
190
+ longer raises. A non-finite JSON-body `page`/`per_page` (`1e400`, NaN) falls back to the
191
+ default instead of a 500 (Paginatable and CursorPaginatable). (#117)
192
+ - **CursorPaginatable**: boundary values are read from the stored column, so an overridden
193
+ attribute reader no longer makes the walk repeat pages forever, and a value changed in memory
194
+ (an `after_initialize` default) no longer keys the cursor on a value the row's sort position
195
+ does not have. (#117)
196
+ - **Callable options**: Throttleable `by:`, WebhookVerifiable `secret:`, Deprecatable
197
+ `notify:`, CounterCacheable `if:` and Auditable `actor:` accept every callable the macro
198
+ validates. A lambda with no required parameter or a block is `instance_exec`'d as before; a
199
+ lambda with a required parameter or a symbol proc is called with the controller (the record
200
+ for `if:`/`actor:`); any other callable object gets it only when its `#call` requires an
201
+ argument, and is otherwise called bare. These used to raise `TypeError`/`ArgumentError` (for
202
+ `actor:`, on every save). Throttleable's `if:`/`unless:` keep their own dispatch. (#117)
203
+ - **Anonymizable**: a callable-object `with:` strategy no longer raises `NoMethodError`
204
+ (`#arity`). (#117) A rewritten slug counts as an erased field for `clear_audit_trail:`,
205
+ `slug: :auto` sees a slug built from an `alias_attribute` of an erased column, erasing an
206
+ Addressable-mapped column clears its `fingerprint:` in the same UPDATE, and
207
+ `prefix:`/`suffix:` passed on a later call are honoured (old scope names retired safely).
208
+ (#118)
209
+ - **Idempotentable**: a replay carries the original's exact `Content-Type`, so
210
+ `application/problem+json` is no longer replayed with `; charset=utf-8`. (#117)
211
+ - **Monetizable**: underscore-grouped amounts (`"5_5"`, `"1_000"`) are rejected (nil) like
212
+ other mis-grouped input. (#117)
213
+ - **Filterable**: a nonzero float operand that underflows to 0.0 (`"1e-400"`) is treated as
214
+ inexact, so `=` matches nothing and `<`/`>` compare correctly. (#117)
215
+ - **Maskable**: presets stringify non-String values before masking instead of returning them
216
+ raw (integer SSN/phone columns were shown unmasked); integral BigDecimal/Float values render
217
+ without `.0`. (#118)
218
+ - **Lockable**: `lock_access!` is a conditional UPDATE — a concurrent stale instance adopts the
219
+ existing lock instead of re-minting the mailed unlock token and re-firing `after_lock`;
220
+ `before_lock` runs after the row is claimed. `register_failed_attempt!` clears a lapsed lock
221
+ the same way: a stale instance used to wipe a lock another request had just taken (a second
222
+ token and `after_lock`, or, below `max_attempts`, an account left unlocked). (#118)
223
+ - **Loader**: every concern file works when required directly (`gem "concerns_on_rails",
224
+ require: false`). The gem-level singletons live in `concerns_on_rails/core`, and a directly
225
+ required file now also wires its sensitive fields into Rails' `filter_parameters` and
226
+ ActiveRecord's `filter_attributes` whether required before, during (eager load,
227
+ `config/initializers`) or after boot — unlock tokens used to be logged in clear and
228
+ `#inspect` showed decrypted values. Deprecatable/Timezoneable require the ActiveSupport time
229
+ extensions they use. (#118)
230
+ - **Sluggable**: a re-declaration refused by the column check no longer leaves its field
231
+ assigned. **Duplicable**: sibling-concern detection no longer autoloads Sluggable/friendly_id
232
+ (or raises without the loader). (#118)
233
+ - **Batch verbs** (`*_all`, `anonymize_all!`, `reencrypt_all!`, `recount_counter_caches!`, the
234
+ cascade) on their per-record path act on exactly the rows an ordered or limited relation
235
+ selects (`order(id: :desc).limit(2).publish_all` touched the two lowest ids), no longer raise
236
+ under `error_on_ignored_order`, and visit each record once over a `has_many` join (hooks ran
237
+ once per joined row); a limited joined slice instantiates one record per row. (#119)
238
+ - **Duplicable**: identity columns (token, slug, sequence number, audit trail, lock state) are
239
+ reset on copies of children that do not include Duplicable; they used to share the original's
240
+ token or raise `RecordNotUnique`. (#119)
241
+ - **Hooked writes** (`publish!`, a Stateable event, `soft_delete!`, `expire!`, `activate!`,
242
+ `anonymize!`): a veto or failure restores every in-memory attribute, including a nested
243
+ in-place edit of an already-read json value, never decrypting anything. (#119)
244
+ - **Auditable**: works on a native `json`/`jsonb` trail column (declare it without a default —
245
+ MySQL rejects a literal JSON `DEFAULT`); earlier double-encoded trails still read, and
246
+ `audit_trail` entries are detached from the column value. (#119)
247
+ - **Searchable**: `mode: :any` strips surrounding whitespace from the query. **Storable**:
248
+ every non-Proc `default:` is deep-duped per read. (#120)
249
+
250
+ - **Encryptable:** a blind-indexed value that Tokenizable, Hashable or Sequenceable generates in
251
+ `before_create` is now fingerprinted when it is generated. Before, the row was stored with a
252
+ NULL blind index, so `find_by_<field>` never found a freshly created record. Works in either
253
+ include or declaration order. (#123)
254
+ - **Sluggable:** a slug whose source is filled in `before_create` (Sequenceable's `into:`
255
+ number, a Hashable code) is now built in the same `INSERT`, not left `nil` until a later save.
256
+ friendly_id's rules still apply: an explicit slug is kept, a conflict gets the uuid suffix,
257
+ and `candidates:` are honored. The late slug is built after every
258
+ `before_validation`/`before_save`, so it gets what those would have done: a reserved word gets
259
+ the uuid suffix, and the slug column's own write-mode Sanitizable and Normalizable rules are
260
+ applied (`normalizable :slug, with: ->(v) { v.tr("-", "_") }` → `"inv_1"`). (#123)
261
+ - **Sluggable:** a slug built earlier in the same save from a source that a later
262
+ `before_validation` then transformed (Sanitizable `on: :write`, Normalizable, your own
263
+ callback, included after Sluggable) is rebuilt from the final value at the start of
264
+ validation. Before, `"<b>Hello</b>"` got the slug `"b-hello-b"`. The rebuild runs before every
265
+ validator (`reserved_words:` included) and every sibling `before_save`, and never touches an
266
+ explicitly assigned slug. (#123)
267
+ - **Auditable:** the creation entry now includes a tracked column generated in `before_create`
268
+ (a Sequenceable number, a Tokenizable/Hashable value, or a slug built from one), whatever the
269
+ include order. (#123)
270
+ - **Hashable:** the report of a generated code to its siblings is a separate `before_create
271
+ :hashable_report_generated_value`, right after the unchanged `before_create
272
+ :assign_hashable_value`. Calling `assign_hashable_value` yourself, outside a save, only
273
+ assigns the value. (#123)
274
+ - **Tokenizable / Hashable:** the uniqueness precheck on an encrypted field goes through the
275
+ blind index. It used to compare against ciphertext and could never detect a collision. (#123)
276
+ - **Lockable:** with optimistic locking (`lock_version`), the raw writes —
277
+ `register_failed_attempt!`'s increment, the `lock_access!` claim, the quiet lapsed-lock reset,
278
+ and `unlock_by_token`'s token claim — bump `lock_version` in SQL, and the bump is now mirrored
279
+ into the instance exactly as Rails' `increment!` does. The same instance, and the record
280
+ `unlock_by_token` returns, save afterwards instead of raising `StaleObjectError`. On Rails
281
+ 7.0+ this also fixes `unlock_by_token` leaving the account locked: the stale instance's
282
+ `update_columns` matched no row while the call reported success. A hook that aborts takes the
283
+ in-memory bump back. (#124)
284
+ - **CounterCacheable:** with `lock_version` on the parent, the parent instance the child's
285
+ association holds (`post.comments.create!`'s `post`, the one passed as `post:`, the old parent
286
+ a foreign-key reassignment left loaded, the parent on destroy) gets the counter delta and the
287
+ `lock_version` bump mirrored in memory — the in-memory half of native `counter_cache`'s
288
+ `increment!`. `post.comments.create!(...); post.update!(...)` no longer raises
289
+ `StaleObjectError`. A counter you assigned and haven't saved is left exactly as assigned,
290
+ still pending (native `increment!` would add the delta to it), while `lock_version` is still
291
+ bumped. When the child's save is rolled back — your `ActiveRecord::Rollback`, a later callback
292
+ raising, a Stateable/HookedWrite hook vetoing, a rolled-back savepoint, a rolled-back
293
+ `transaction(joinable: false)` — the mirror is taken back off the parent instance, so it stays
294
+ saveable (native `counter_cache` leaves it stale). Only the adjustments that were actually
295
+ rolled back are undone, and a counter or `lock_version` you assigned after the mirror is left
296
+ as you assigned it. The undo holds the parent only weakly, once per parent and transaction, so
297
+ long batches keep nothing in memory, and the parent stays `Marshal`-dumpable. An
298
+ `attr_readonly` counter is not mirrored. Known limit: a parent destroyed later in the same
299
+ rolled-back transaction keeps its mirror (reload it). Without `lock_version`, nothing changes.
300
+ (#124)
301
+ - **Encryptable:** `reencrypt!` / `reencrypt_all!` no longer bump `lock_version`, since the
302
+ column is pinned to itself in the rotation UPDATE. A key rotation changes no value, so records
303
+ open in edit forms during the sweep no longer turn into `StaleObjectError`s. The ciphertext
304
+ guard is unchanged. (#124)
305
+ - **Stateable:** `lock: true` works on records with unsaved changes (`ticket.note = "fixed";
306
+ ticket.resolve!` saves the note with the state, as `lock: false` does); `with_lock`'s reload
307
+ used to raise "Locking a record with unpersisted changes is not supported". For such a record
308
+ only the state column is read (`SELECT <state> ... FOR UPDATE`) and adopted, and the guard
309
+ decides from it (a disallowed event raises `InvalidTransition`). On a model with optimistic
310
+ locking, a record with unsaved changes whose row has changed since it was loaded raises
311
+ `StaleObjectError`: its changes were made against a stale copy. A deleted row raises
312
+ `RecordNotFound`. A record without unsaved changes is still reloaded under the lock, exactly
313
+ as before. (#124)
314
+ - **Activatable:** `toggle_active!` works on records with unsaved changes. It used `with_lock`
315
+ too, and raised the same "unpersisted changes" error. Such a record now has only its flag read
316
+ under the lock; it flips the row's value, and its changes save with the flip. A record without
317
+ unsaved changes is still reloaded under the lock. (#124)
318
+ - **Encryptable:** `type: :datetime` stores a frozen or non-UTC `Time` correctly and never
319
+ modifies it. `Time#utc` converted the caller's value in place, and a frozen `Time` was
320
+ silently saved as `NULL`. (#125)
321
+ - **Auditable:** recording a tracked datetime no longer converts the record's (the caller's)
322
+ `Time` to UTC in place, and a frozen `Time` no longer makes the save raise `FrozenError`.
323
+ (#125)
324
+ - **CursorPaginatable:** minting a cursor no longer converts the page-boundary record's
325
+ timestamp to UTC in place. (#125)
326
+ - **CursorPaginatable:** a cursor carrying a timestamp outside years 0001..9999 is an
327
+ `InvalidCursor` (400). It used to reach the WHERE clause and raise `DatetimeFieldOverflow` on
328
+ PostgreSQL (a 500), and SQLite compared it as text. (#125)
329
+ - **Filterable:** date/time comparison operands outside `0001-01-01`..`9999-12-31` (the portable
330
+ SQL range) never reach the database. They are answered per operator, like out-of-range
331
+ numbers: `?happened_at_lt=300000-01-01` is every non-NULL row, `_gt` none, and in equality /
332
+ `in` / `not` / `not_in` such a value equals nothing. They used to raise
333
+ `DatetimeFieldOverflow` on PostgreSQL (a 500), and SQLite answered year-10000 comparisons
334
+ backwards. (#125)
335
+
336
+ ### Internal
337
+ - New support modules: `Support::Callable`, `Support::SlugSources`,
338
+ `Support::AssociationScope`; gem-level singletons moved to `lib/concerns_on_rails/core.rb`. A
339
+ real-Rails-app subprocess spec covers filter wiring for direct requires.
340
+ - Dev dependencies: Rails 8.1.4, permittable 0.10.0; the permittable compatibility suite accepts
341
+ both the pre-0.9 and 0.9+ validation messages (the gemspec still allows `~> 0.1`). (#121, #126)
342
+ - New support modules: `Support::Locking` (lock_version mirroring, row-locked reads),
343
+ `Support::TimeValue` (verb time arguments, virtual datetimes, the portable year range),
344
+ `Support::GeneratedValues` and `Support::EncryptedLookup` (values generated in
345
+ `before_create`, equality lookups over encrypted columns). (#123–#125)
346
+ - Documented limits: see `docs/concerns/sequenceable.md` (zones across STI leaves under an
347
+ abstract declarer; re-declaring an owner after its subclasses) and
348
+ `docs/concerns/stateable.md` (a subclass stub outlives a later parent re-declaration).
349
+ `unlock_access!`'s unconditional write is left for a follow-up. Also documented: a
350
+ CounterCacheable parent destroyed later in the same rolled-back transaction keeps its
351
+ in-memory mirror (reload it); Encryptable `find_by_<field>` does not apply the field's Rails
352
+ `normalizes`; on Rails 7.2+ an Encryptable `:datetime` skip list must precede the `encryptable`
353
+ line or be followed by a re-declaration (`docs/concerns/encryptable.md`).
354
+
355
+ ## 1.30.0 (2026-09-27)
356
+
357
+ The fixes from the 2026-09-23 whole-gem audit of 1.29.0: 37 bugs (8 HIGH) across six PRs
358
+ (#109–#114). It is a minor release, not a patch, because a few fixes add an option or a method,
359
+ and several change observable behaviour. Read **Changed** before upgrading. No new
360
+ concerns and no migrations. Every fix ships with a regression spec that fails on 1.29.0.
361
+ 2146 examples, 0 failures (2121 on Rails 6.0), green on PostgreSQL and MySQL.
362
+
363
+ ### Changed
364
+ - **Throttleable: counters that use the default name reset once on upgrade.** The default
365
+ rule name changes from `"rule<n>"` to `"<DeclaringController>#rule<n>"`, so the cache key
366
+ changes, and so does the `rule` field of `rate_limited.concerns_on_rails`. Rules with an
367
+ explicit `name:` are not affected. (#109)
368
+ - **WebhookVerifiable: rule lookup is by specificity.** A rule that names the action beats any
369
+ catch-all, whichever class declared either. The most-derived class wins, then declaration
370
+ order. Such actions no longer inherit a parent catch-all's `replay:`/`tolerance:`/scheme, so
371
+ declare those on the subclass rule if you still want them. A catch-all declared before
372
+ specific rules no longer shadows them. `commit_webhook_replay_claim` is now private. (#109)
373
+ - **Cacheable**: POST/PUT/PATCH/DELETE responses, and statuses other than
374
+ 200/203/204/206/304, no longer receive the rule's positive `Cache-Control`. `no_store` is
375
+ unchanged. `Vary` merging is now case-insensitive, leaves `Vary: *` alone, and keeps Rails'
376
+ own `Vary: Accept`. (#109)
377
+ - **Idempotentable: an unreachable store returns 503 `idempotency_store_unavailable`**
378
+ (with `Retry-After`) instead of a permanent 409 `idempotency_conflict`. The action still
379
+ does not run. Unreachable means both claims failed and both reads returned nil. (#109)
380
+ - **Filterable: numeric operands are exact, never truncated.** On an integer column,
381
+ `?stock=5.5` now matches nothing (it used to match 5), and `?stock=1e1` matches 10 (it used
382
+ to match 1). `?stock_lt=<huge>` returns every non-NULL row. One garbage member fails a whole
383
+ `in`/`not_in` list closed, and garbage fails `not` closed too. A JSON `true` or boolean
384
+ against a numeric column fails closed. `type: :integer` on a float/decimal column no longer
385
+ truncates. A numeric `type:` on a `with:` lambda pre-casts strictly, and fails closed
386
+ without calling the lambda. Operands over 100 characters are refused. (#113)
387
+ - **Sequenceable (STI)**: declared on an STI **base** class, all subclasses now share one
388
+ table-wide counter. They used to each start at 1 and collide on a shared unique index.
389
+ **Upgrade note:** a subclass that *inherited* a base declaration and numbered per type now
390
+ jumps once to the table-wide MAX + 1. Declare `scope: :type` to keep per-type numbering, and
391
+ index `(type, <column>)`. `next_<field>` under `scope: :type` previews the receiving class's
392
+ sequence. (#111)
393
+ - **Hooked verbs** (Publishable, SoftDeletable, Expirable, Activatable, Anonymizable,
394
+ Stateable): a hook's `ActiveRecord::Rollback` now vetoes the write for real. The verb
395
+ returns `false`, and the `*_all` verbs raise `RecordNotSaved`. The exception is
396
+ `anonymize_all!`, which skips the vetoed record and doesn't count it. After any aborted
397
+ write, the written attributes return to their pre-call in-memory values. (#114)
398
+ - **Publishable**: with a boolean column, `Post.published.new` no longer pre-sets the flag.
399
+ The scope is now `("published" = TRUE)`, or `<> FALSE` on Rails 6.0. (#114)
400
+ - **Expirable**: `expire!`/`expire_all` with nil or a blank time now mean "now" (they used to
401
+ mean "never expires"). An unparseable time raises `ArgumentError`. (#114)
402
+ - **Expirable / Activatable / Schedulable**: `prefix:`/`suffix:` now also define affixed
403
+ predicates (`term_active?`, `window_expired?`, …). The macro raises if one would shadow a
404
+ different column's query method. (#114)
405
+ - **Auditable**: new entries store `"at"` with microseconds. Second-precision entries still
406
+ parse. (#111)
407
+ - **Hashable**: a `:custom` alphabet with duplicate characters is de-duplicated, with a
408
+ deprecation warning (it will raise in 2.0). Fewer than 2 distinct characters raises. (#111)
409
+
410
+ ### Added
411
+ - **Idempotentable**: `on_store_unavailable:` (`:reject` default, or `:proceed` to run the
412
+ action without deduplication). (#109)
413
+ - **Anonymizable**: `slug:` (`:auto` default, `true`, `false`). It rewrites a friendly_id slug
414
+ built from an anonymized column to a random slug, and deletes its history rows. (#110)
415
+ - **Taggable**: `Model.normalize_tags!` rewrites rows written around the callbacks (e.g.
416
+ `"ruby, rails"`) to the form `tagged_with` matches. It returns a count. (#112)
417
+ - **Support::UniqueRetry**: `savepoint:` runs each attempt in its own savepoint, so a retry
418
+ works inside a caller's transaction on PostgreSQL. (#110, #111)
419
+
420
+ ### Fixed
421
+ - **Encryptable**: `Model.needs_reencryption` / `reencrypt_all!` now cover the whole table.
422
+ Rows hidden by a `default_scope` used to be skipped, and became undecryptable once the old
423
+ key was dropped. On a relation, both cover exactly that relation. On Rails 6.0–7.0,
424
+ `<field>_ciphertext` returns the stored ciphertext after a save, and `reencrypt!` works
425
+ right after a save. (#110)
426
+ - **Anonymizable**: erasure is never blocked by ciphertext that won't decrypt, and one bad
427
+ row no longer rolls back `anonymize_all!`. A slug built from erased PII is replaced in the
428
+ same UPDATE. (#110)
429
+ - **Storable**: `where_<key>` on an `encryptable` column raises instead of silently matching
430
+ nothing (or every row). (#110)
431
+ - **CounterCacheable**: a destroy decrements only when its DELETE removed a row, and reads
432
+ persisted values. `belongs_to primary_key:` is honoured. A child destroyed by its parent's
433
+ `dependent: :destroy` no longer decrements that parent, which also fixes
434
+ `StaleObjectError`. (#111)
435
+ - **Duplicable**: counter-cache columns are zeroed on the copy, so a deep copy counts its
436
+ children once. `duplicate!` re-reads the counters. (#111)
437
+ - **Sequenceable**: `assign_<field>!` restores the number when the save fails, so a retry
438
+ draws a fresh one. (#111)
439
+ - **Hashable / Tokenizable**: uniqueness prechecks see sibling STI rows, and
440
+ `regenerate_<field>!` retries work inside a caller's transaction. (#111)
441
+ - **Lockable / Addressable**: models load again when the schema is unreachable. (#111)
442
+ - **Aliasable**: `accepts_nested_attributes_for :alias` now updates and destroys existing
443
+ children. It used to save only new ones. (#112)
444
+ - **Sanitizable**: `with: :strip, on: :write` stores plain text (`"Tom & Jerry"`, not
445
+ `"Tom &amp; Jerry"`). It stays idempotent and linear-time, and `sanitize_all!` repairs
446
+ double-escaped rows. (#112)
447
+ - **Addressable**: `if:`/`unless:` from later calls and subclasses are honoured. (#112)
448
+ - **Monetizable**: the writer reads the concern's own formatted output (`"$1,234.50"`).
449
+ Garbage, NaN, Infinity and oversized input cast to nil instead of raising. (#112)
450
+ - **Normalizable**: whitespace presets treat Unicode whitespace as whitespace. (#112)
451
+ - **Maskable**: `:email`/`:phone` fail closed. A malformed value gets the full mask instead of
452
+ being returned raw. (#112)
453
+ - **Taggable**: `taggable_by` raises on an empty `delimiter:`. (#112)
454
+ - **CursorPaginatable**: `max_per_page: 0` no longer lets a huge `per_page` overflow `LIMIT`
455
+ (a 500). Both paginators share `Support::ScalarParam.per_page`, with a 1,000,000 ceiling. A
456
+ cursor whose values can't be bound is a 400 `invalid_cursor`. (#113)
457
+ - **Filterable**: `1e99999999` on an unconstrained numeric column no longer costs ~1.3 s and
458
+ 476 MB per request. `contains`/`starts_with` on non-string columns match nothing, instead of
459
+ a PostgreSQL 500. (#113)
460
+ - **Localizable / Timezoneable**: `rescue_from` handlers render under the resolved
461
+ locale/zone. `Accept-Language` ties keep header order, and `q` is case-insensitive. (#113)
462
+ - **Stateable**: the in-memory state and `<state>_at` are restored after a vetoed transition
463
+ or `RecordInvalid`. (#114)
464
+ - **Rails 6.0**: a hooked write aborted inside a caller's transaction no longer makes a
465
+ record created earlier in it INSERT twice. (#114)
466
+
467
+ ### Internal
468
+ - New `Support::HookedWrite` (the one before-hook → write → after-hook path) and
469
+ `Support::NumericOperand` (Filterable's exact numeric reader). (#113, #114)
470
+ - Development dependencies: rubocop 1.91, simplecov 1.2, permittable 0.7 in the lockfile. (#106)
471
+ - CLAUDE.md describes the new behaviour. (#115)
472
+
3
473
  ## 1.29.0 (2026-09-21)
4
474
 
5
475
  CI now runs **every Rails line the gemspec admits** — 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 —