concerns_on_rails 1.30.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +352 -0
  3. data/README.md +110 -42
  4. data/lib/concerns_on_rails/configuration.rb +2 -1
  5. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +225 -52
  6. data/lib/concerns_on_rails/controllers/deprecatable.rb +7 -3
  7. data/lib/concerns_on_rails/controllers/filterable.rb +58 -5
  8. data/lib/concerns_on_rails/controllers/idempotentable.rb +29 -1
  9. data/lib/concerns_on_rails/controllers/paginatable.rb +124 -1
  10. data/lib/concerns_on_rails/controllers/permittable.rb +1 -0
  11. data/lib/concerns_on_rails/controllers/throttleable.rb +10 -2
  12. data/lib/concerns_on_rails/controllers/timezoneable.rb +2 -0
  13. data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +13 -7
  14. data/lib/concerns_on_rails/core.rb +93 -0
  15. data/lib/concerns_on_rails/models/activatable.rb +14 -4
  16. data/lib/concerns_on_rails/models/aliasable.rb +1 -0
  17. data/lib/concerns_on_rails/models/anonymizable.rb +144 -33
  18. data/lib/concerns_on_rails/models/auditable.rb +81 -19
  19. data/lib/concerns_on_rails/models/counter_cacheable.rb +389 -19
  20. data/lib/concerns_on_rails/models/duplicable.rb +152 -59
  21. data/lib/concerns_on_rails/models/encryptable.rb +337 -44
  22. data/lib/concerns_on_rails/models/expirable.rb +29 -10
  23. data/lib/concerns_on_rails/models/hashable.rb +30 -3
  24. data/lib/concerns_on_rails/models/lockable.rb +174 -26
  25. data/lib/concerns_on_rails/models/normalizable.rb +46 -3
  26. data/lib/concerns_on_rails/models/publishable.rb +54 -11
  27. data/lib/concerns_on_rails/models/schedulable.rb +18 -3
  28. data/lib/concerns_on_rails/models/searchable.rb +25 -2
  29. data/lib/concerns_on_rails/models/sequenceable.rb +209 -23
  30. data/lib/concerns_on_rails/models/sluggable.rb +161 -7
  31. data/lib/concerns_on_rails/models/soft_deletable.rb +33 -8
  32. data/lib/concerns_on_rails/models/sortable.rb +1 -0
  33. data/lib/concerns_on_rails/models/stateable.rb +321 -20
  34. data/lib/concerns_on_rails/models/storable.rb +71 -44
  35. data/lib/concerns_on_rails/models/taggable.rb +21 -0
  36. data/lib/concerns_on_rails/models/tokenizable.rb +51 -7
  37. data/lib/concerns_on_rails/railtie.rb +41 -3
  38. data/lib/concerns_on_rails/support/affix.rb +67 -2
  39. data/lib/concerns_on_rails/support/association_scope.rb +121 -0
  40. data/lib/concerns_on_rails/support/batch_ops.rb +87 -2
  41. data/lib/concerns_on_rails/support/callable.rb +70 -0
  42. data/lib/concerns_on_rails/support/encrypted_lookup.rb +53 -0
  43. data/lib/concerns_on_rails/support/generated_values.rb +63 -0
  44. data/lib/concerns_on_rails/support/hooked_write.rb +76 -61
  45. data/lib/concerns_on_rails/support/locking.rb +126 -0
  46. data/lib/concerns_on_rails/support/masker.rb +35 -8
  47. data/lib/concerns_on_rails/support/money.rb +11 -3
  48. data/lib/concerns_on_rails/support/numeric_operand.rb +26 -5
  49. data/lib/concerns_on_rails/support/scalar_param.rb +7 -2
  50. data/lib/concerns_on_rails/support/sequence_calculator.rb +50 -2
  51. data/lib/concerns_on_rails/support/slug_sources.rb +60 -0
  52. data/lib/concerns_on_rails/support/time_value.rb +306 -0
  53. data/lib/concerns_on_rails/version.rb +1 -1
  54. data/lib/concerns_on_rails.rb +14 -74
  55. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01db17beb47c485890bfd3c755086624432941547fdd77ecc52536164a2a3404
4
- data.tar.gz: 8aeca6a8b1e4d901a8d1b128a75ff27f25f5f85138034819b75ed66cfb16cc4e
3
+ metadata.gz: 493ce0d913905625ffaabf033052a0ccc631aa22fe3c9a06a04c776ad4a80f67
4
+ data.tar.gz: 022bb7a0230e1c9c8ebc210c43245cf458c9248f3810f24f02c731695c0c046d
5
5
  SHA512:
6
- metadata.gz: 7615ea7a35f68bffb71e55975a9ad3097d683287dd1cddc81d4d0397343fba1d38289a8589db573ebe3a5f310ed5e2ddf43f621082f6bc16f56587402594df0e
7
- data.tar.gz: '093ac4ae4b2de63d42722de0f75b23ce9423cc8528fa6c1768e849a515079d501b5df095e64c54227cd967d355ce302c63d55fe4467b7903083770ff38115adc'
6
+ metadata.gz: 55cd86cfb3b04af3972060c2d4455dca7fe2ee7abfdccfa029c75ee6a9caee4b6700eeb128dd3f15aec5c52460af7ca5936935185593788d02df9c050044e3fa
7
+ data.tar.gz: 750dda120eb24d9e3d121a8e4ec1828e2d26303677f5758bc415f138fa4497845e1b67219203dc593ad5fe55a1a406e77d544b292f1639d4aa56546bb855010a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,357 @@
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
+
3
355
  ## 1.30.0 (2026-09-27)
4
356
 
5
357
  The fixes from the 2026-09-23 whole-gem audit of 1.29.0: 37 bugs (8 HIGH) across six PRs