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