concerns_on_rails 1.33.0 → 1.34.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 +273 -0
- data/README.md +33 -22
- data/lib/concerns_on_rails/controllers/authorizable.rb +47 -12
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +61 -12
- data/lib/concerns_on_rails/controllers/error_handleable.rb +7 -2
- data/lib/concerns_on_rails/controllers/filterable.rb +85 -3
- data/lib/concerns_on_rails/controllers/includable.rb +41 -6
- data/lib/concerns_on_rails/controllers/paginatable.rb +49 -10
- data/lib/concerns_on_rails/controllers/respondable.rb +6 -8
- data/lib/concerns_on_rails/controllers/secure_headable.rb +50 -6
- data/lib/concerns_on_rails/controllers/timezoneable.rb +29 -9
- data/lib/concerns_on_rails/encryption.rb +29 -6
- data/lib/concerns_on_rails/legacy_aliases.rb +15 -3
- data/lib/concerns_on_rails/models/addressable.rb +2 -0
- data/lib/concerns_on_rails/models/anonymizable.rb +7 -2
- data/lib/concerns_on_rails/models/auditable.rb +7 -11
- data/lib/concerns_on_rails/models/counter_cacheable.rb +65 -4
- data/lib/concerns_on_rails/models/duplicable.rb +27 -5
- data/lib/concerns_on_rails/models/encryptable.rb +35 -12
- data/lib/concerns_on_rails/models/expirable.rb +32 -12
- data/lib/concerns_on_rails/models/lockable.rb +24 -7
- data/lib/concerns_on_rails/models/monetizable.rb +6 -4
- data/lib/concerns_on_rails/models/normalizable.rb +22 -21
- data/lib/concerns_on_rails/models/publishable.rb +23 -1
- data/lib/concerns_on_rails/models/sanitizable.rb +24 -15
- data/lib/concerns_on_rails/models/sequenceable.rb +1 -1
- data/lib/concerns_on_rails/models/sluggable.rb +16 -2
- data/lib/concerns_on_rails/models/soft_deletable.rb +41 -10
- data/lib/concerns_on_rails/models/sortable.rb +49 -6
- data/lib/concerns_on_rails/models/stateable.rb +19 -1
- data/lib/concerns_on_rails/models/storable.rb +2 -12
- data/lib/concerns_on_rails/models/taggable.rb +51 -6
- data/lib/concerns_on_rails/models/tokenizable.rb +8 -3
- data/lib/concerns_on_rails/support/batch_ops.rb +64 -3
- data/lib/concerns_on_rails/support/contextual_destroy.rb +9 -5
- data/lib/concerns_on_rails/support/error_envelope.rb +32 -1
- data/lib/concerns_on_rails/support/hooked_write.rb +29 -15
- data/lib/concerns_on_rails/support/money.rb +12 -0
- data/lib/concerns_on_rails/support/native_json.rb +31 -0
- data/lib/concerns_on_rails/support/numeric_operand.rb +17 -1
- data/lib/concerns_on_rails/support/scalar_param.rb +10 -6
- data/lib/concerns_on_rails/support/sequence_calculator.rb +20 -5
- data/lib/concerns_on_rails/support/transform_backstop.rb +100 -3
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9acfbd10f8e27c5a4535a3486fec52255d7d5e997e3b7654547e864802bdcf58
|
|
4
|
+
data.tar.gz: 31701400ec820ac47a7cd26e8b68579dab0fba3d43bd1d8cea87b63c60f244ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fc6561854bcdb26fbbae74b6f9b623bd9af28b240b313f56dcaa5c40da9c03726db39908e2b6ddb6af67d326fdd1f597c4ec81d31fc951711753320f44cf533
|
|
7
|
+
data.tar.gz: 4471742ad951b6b0dfd9998c93c43ba2e6627d44e5d6b94c0e48c366dcd5732f0c51b5df7886d3d88f0c6a71cf63a33f1a6695adfa1bf8292b8d51eb91d49609
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,278 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.34.0 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
A correctness release: it fixes the 43 bugs proven by the 2026-10-29 audit of 1.33.0. Some fixes change behaviour,
|
|
6
|
+
and five configurations that used to be silently broken now raise at class load. Read **Upgrading** first.
|
|
7
|
+
|
|
8
|
+
### Upgrading
|
|
9
|
+
|
|
10
|
+
**Class-load (boot) raises for configurations that were silently broken.**
|
|
11
|
+
|
|
12
|
+
- [ ] **CounterCacheable, composite keys:** `counter_cacheable_by` raises `ArgumentError` for a `belongs_to` with a
|
|
13
|
+
composite `foreign_key:` (`[:shop_id, :order_id]`), a composite association primary key or `query_constraints:`
|
|
14
|
+
(Rails 7.1+), as it does for a polymorphic one. It was accepted, never counted the children and made
|
|
15
|
+
`recount_counter_caches!` raise invalid SQL. Count on a single-column key instead. A model-level
|
|
16
|
+
`query_constraints` must be declared before `counter_cacheable_by`. (GRAPH-2)
|
|
17
|
+
- [ ] **CursorPaginatable, `predicate: :row`:** `predicate: :row` with a mixed-direction `order:` or `order_presets:`
|
|
18
|
+
entry raises `ArgumentError` at class load (also when a later re-declaration combines with an inherited one), and a
|
|
19
|
+
per-call `cursor_paginated(order:)` with mixed directions raises before page 1. This includes a nullable ordering
|
|
20
|
+
column, which used to fall back to the OR-expansion silently. Switch to `predicate: :auto` (it falls back to the
|
|
21
|
+
OR-expansion for mixed directions) or make the directions uniform. (PARAMS-4)
|
|
22
|
+
- [ ] **Stateable, event named `transition`:** a transition event whose (affixed) name is `transition` raises
|
|
23
|
+
`ArgumentError` at the macro. It generated a `transition!` that always raised from the generic
|
|
24
|
+
`before_/after_transition` hooks. Rename the event or use `prefix:`/`suffix:`. (STATE-6)
|
|
25
|
+
- [ ] **Authorizable, `require_role`:** `require_role` with an empty list (`require_role []`, `[[]]`) or a nil/blank
|
|
26
|
+
role (`require_role ENV["ADMIN_ROLE"]&.to_sym` unset) raises `ArgumentError` at class load; a blank role used to
|
|
27
|
+
admit actors whose role is `""` or nil. (HTTP-2)
|
|
28
|
+
- [ ] **Encryptable, per-field `key:`:** a DECLARED per-field `key:` that resolves to nil or `""` (a Proc over a
|
|
29
|
+
credential that is not there yet, an explicit `key: nil`, an eagerly nil `ENV["X"]` / credential) is a missing key:
|
|
30
|
+
it raises `MissingKeyError` naming the field on write, read and blind-index lookup (or leaves the value plaintext
|
|
31
|
+
under `on_missing_key: :passthrough`) instead of silently encrypting under the global key. Only an OMITTED `key:`
|
|
32
|
+
uses the gem-level key. `key:` must be a String or respond to `call`; `key: false`, `key: 123` or `key: :sym` raises
|
|
33
|
+
`ArgumentError` at the macro. Rows already written under the global key are NOT repaired by `reencrypt_all!`
|
|
34
|
+
(it skips per-field fields); they fail to decrypt under the field key. For each such row, decrypt the raw column
|
|
35
|
+
value with the global key (`ConcernsOnRails::Support::Encryptor.decrypt(raw, key: <global key material>,
|
|
36
|
+
salt: ConcernsOnRails.encryption.key_derivation_salt)`; read `raw` with `<field>_ciphertext`, since the model's
|
|
37
|
+
reader raises for such rows), then write the plaintext back through the model while the field key resolves, so it
|
|
38
|
+
is re-sealed under the field key.
|
|
39
|
+
Rows left plaintext under `:passthrough` only need a plain re-save once the key resolves. (DATA-2)
|
|
40
|
+
|
|
41
|
+
**Behaviour changes.**
|
|
42
|
+
|
|
43
|
+
- [ ] **Normalizable / Sanitizable / Taggable / Addressable:** Normalizable no longer re-normalizes a later transform's
|
|
44
|
+
output at `before_save`, so with Normalizable included BEFORE a Sanitizable/Taggable/Addressable rule on the same
|
|
45
|
+
column its rule no longer gets "the last word" (`squish` then `strip` on: :write stores `" a b"` where it stored
|
|
46
|
+
`"a b"`). Include Normalizable AFTER the other transform to normalize its output. (TRANS-2)
|
|
47
|
+
- [ ] **Taggable:** with a self-overlapping delimiter (`"--"`, `"||"`, `" | "`) a tag is trimmed of a trailing part
|
|
48
|
+
that is the start of the delimiter, and of a leading part that is the end of it: `"a-"` becomes `"a"` and `"-b"`
|
|
49
|
+
becomes `"b"` under `"--"`. Delimiters that do not overlap themselves are unaffected. Stored tags with a leading
|
|
50
|
+
straddling part are not migrated; `Model.normalize_tags!` repairs them. (TRANS-4)
|
|
51
|
+
- [ ] **Paginatable / Includable:** re-declaring `paginate_by` or `includable` in a subclass changes only what it
|
|
52
|
+
passes; every omitted option now keeps its inherited value (before it reset to the gem default, for example a
|
|
53
|
+
parent `max_per_page: 20` became 200). A subclass that relied on the reset must pass the option explicitly.
|
|
54
|
+
For `includable` the association list still replaces the parent's, and an inherited `default:` path the new list no
|
|
55
|
+
longer contains is dropped with a stderr warning at class load. (PARAMS-2)
|
|
56
|
+
- [ ] **Filterable, `_null`:** `?<field>_null=` and `?field[null]=` read only `true/false/1/0/t/f/yes/no`
|
|
57
|
+
(case-insensitive) or a real boolean; any other NON-BLANK value (`maybe`, `on`, `off`, `nope`) matches nothing
|
|
58
|
+
instead of being read as `IS NULL` (`off` used to be `IS NOT NULL`). A blank value is skipped as before, and
|
|
59
|
+
surrounding whitespace is ignored. (PARAMS-6)
|
|
60
|
+
- [ ] **Filterable, encrypted columns:** on an `encryptable` column equality and `in` go through the blind index and every
|
|
61
|
+
other operator (`not`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`) fails closed (`none`); a field
|
|
62
|
+
without a blind index fails closed for every operator but `null`. Add `blind_index: true` to filter an encrypted
|
|
63
|
+
field by equality. (PARAMS-1)
|
|
64
|
+
- [ ] **Filterable, `with:` lambdas:** a lambda declared with a `type:` receives no Array/Hash/Parameters value; such a
|
|
65
|
+
value fails the filter closed (`none`) for every `type:`. (PARAMS-S4)
|
|
66
|
+
- [ ] **Filterable, float columns:** a decimal literal that a double does not read back as (more digits than a Float
|
|
67
|
+
keeps, e.g. `0.1000000000000000000001`) equals nothing and compares as the largest double at or below it. (PARAMS-8)
|
|
68
|
+
- [ ] **Paginatable / CursorPaginatable, `page` / `per_page`:** read as exact whole numbers (`1e3` is 1000, `2e0` is 2);
|
|
69
|
+
a fractional (`2.9`), trailing-garbage (`10abc`) or unreadable value falls back to the default instead of its
|
|
70
|
+
leading digits, and a value beyond 100 digits falls back to the default instead of clamping. (PARAMS-7)
|
|
71
|
+
- [ ] **CursorPaginatable, SQLite:** a hand-made cursor whose String boundary carries a NUL byte is accepted on SQLite
|
|
72
|
+
(it cannot be told from a minted one) instead of answering `InvalidCursor`. (PARAMS-5)
|
|
73
|
+
- [ ] **Publishable:** `unpublish!` on a stored draft (blank saved column; false/NULL on a boolean) is a no-op like
|
|
74
|
+
`publish!`: it returns `true`, writes nothing and fires no `before/after_unpublish`. A scheduled row is still
|
|
75
|
+
cleared with the hooks. (STATE-5)
|
|
76
|
+
- [ ] **SoftDeletable:** `soft_delete!`/`restore!` decide "already done" from the SAVED stamp. Assigning the stamp
|
|
77
|
+
without saving no longer makes the verb return `true` while writing nothing: it writes, fires the hooks and cascades.
|
|
78
|
+
A no-op verb now saves OTHER pending changes (previously dropped; a validation can make it return `false`). (STATE-12)
|
|
79
|
+
- [ ] **Expirable:** `expire_in!(nil)` and `extend_expiry!(by: nil)` (anything but an `ActiveSupport::Duration` or a
|
|
80
|
+
`Numeric`) raise the concern's `ArgumentError` instead of a bare `TypeError`; an object that merely responds to
|
|
81
|
+
`to_i` is refused too. (STATE-10)
|
|
82
|
+
- [ ] **Expirable, `date` columns:** `expire_in!(15.minutes)` on a `date` column writes today's date, so it now fires the
|
|
83
|
+
hooks, because the record is `expired?` (unchanged for UTC process zones). (STATE-2)
|
|
84
|
+
- [ ] **Anonymizable:** `anonymize!` returns `false` (no `after_anonymize`) when its UPDATE reached no row, and
|
|
85
|
+
`anonymize_all!` no longer counts such a row. (STATE-9)
|
|
86
|
+
- [ ] **Lockable:** `register_failed_attempt!` on a `readonly!` or destroyed record raises before incrementing, so
|
|
87
|
+
nothing is written; an already-locked record still just answers the count. (DATA-4)
|
|
88
|
+
- [ ] **Monetizable:** the writer casts an amount the cents column cannot hold to `nil`, like other oversized input
|
|
89
|
+
(a 4-byte integer column overflows at about 21.4 million units). (TRANS-S3)
|
|
90
|
+
- [ ] **Authorizable:** the default denial `message:` follows the status: 404 renders ErrorHandleable's exact
|
|
91
|
+
"Resource not found", 401 "Unauthorized", anything else "Forbidden". A `status: :not_found` / `:unauthorized` rule
|
|
92
|
+
without `message:` changes its body message and the `authorization_denied` payload's `message`; an explicit
|
|
93
|
+
`message:` still wins. (HTTP-1)
|
|
94
|
+
- [ ] **Authorizable, `require_role`:** Array arguments are flattened, so `require_role %i[admin editor]` and
|
|
95
|
+
`require_role ADMIN_ROLES` work (they used to deny everyone). (HTTP-2)
|
|
96
|
+
- [ ] **Timezoneable:** responses gain `Vary: Time-Zone` whenever the `Time-Zone` header source is on (default; no longer
|
|
97
|
+
only with `response_header:`) and `Vary: Cookie` whenever the cookie source is on. New `vary:` option (default
|
|
98
|
+
true; `vary: false` opts out). `response_header:` now controls only `X-Time-Zone`. (HTTP-3)
|
|
99
|
+
- [ ] **ErrorHandleable:** the RecordNotDestroyed 422 renders "Resource could not be deleted" instead of Rails'
|
|
100
|
+
"Failed to destroy <Model> with id=<id>"; the record's `errors.full_messages` still ride as `details`. The
|
|
101
|
+
`message` of that 422 and of the `handled_error` payload changed. (HTTP-4)
|
|
102
|
+
- [ ] **Respondable / ErrorEnvelope, status symbols:** on Rack >= 3.1 without Rails 8.1's own rewrite (Rails
|
|
103
|
+
7.1/7.2/8.0) the gem renders the renamed symbol (`:unprocessable_content`, ...), so `rendered[:status]` is Rack
|
|
104
|
+
dependent; specs asserting `:unprocessable_entity` should compare to `ErrorEnvelope.renderable_status(:unprocessable_entity)`.
|
|
105
|
+
An app's OWN `render_error` override still receives the raw `:unprocessable_entity`; one that calls `render` with
|
|
106
|
+
it logs Rack's deprecation on Rack >= 3.1. (HTTP-5)
|
|
107
|
+
- [ ] **SecureHeadable, `:hsts`:** the `:hsts` preset is skipped when `Rails.application.config.force_ssl` is on (read
|
|
108
|
+
per request), so Rails' own HSTS (`config.ssl_options[:hsts]`) is no longer overridden by the preset's one-year
|
|
109
|
+
value. A custom `"Strict-Transport-Security"` pair with a different value is still sent. (HTTP-6)
|
|
110
|
+
- [ ] **SecureHeadable, header names:** names compare case-insensitively: a lower-case custom
|
|
111
|
+
`"strict-transport-security"` is withheld over plain HTTP, and a lower-case pair beside a preset
|
|
112
|
+
(`"x-frame-options"` + `:deny_frame`) is ONE header (later value wins, first-registered spelling kept), written
|
|
113
|
+
under the spelling the response already carries. (HTTP-7)
|
|
114
|
+
- [ ] **SoftDeletable:** after an association `destroy_all` the association cache is reset rather than marked
|
|
115
|
+
loaded-and-empty, so a `default_scope: false` / `-> { with_deleted }` association still shows its soft-deleted rows
|
|
116
|
+
on the next read (and `size`); one extra query on the next read. (STATE-4)
|
|
117
|
+
- [ ] **Batch verbs, partial `select`:** every `*_all` verb and a SoftDeletable relation `destroy_all` over a relation
|
|
118
|
+
with a partial `select` load whole rows (`table.*` is added; computed aliases a HAVING/ORDER reads stay; joins and
|
|
119
|
+
wheres kept) instead of raising `MissingAttributeError`. Accepted limits: a joined-table column, a Rails 7.1+ Hash
|
|
120
|
+
select or a comma-joined String repeating a column beside the star can still repeat `id` in the DISTINCT key
|
|
121
|
+
subquery on PostgreSQL/MySQL; a direct `BatchOps.each_record` caller selecting without the primary key still
|
|
122
|
+
gets nil ids. (STATE-7)
|
|
123
|
+
- [ ] **Stateable, `transition_all`:** a record failing validation raises `ActiveRecord::RecordNotSaved` (carrying the
|
|
124
|
+
record; batch rolled back as before) instead of `RecordInvalid`; rescue `RecordNotSaved`. Another record's failed
|
|
125
|
+
`save!` inside a hook still propagates as `RecordInvalid`. The single `<event>!` still raises `RecordInvalid`.
|
|
126
|
+
(STATE-8)
|
|
127
|
+
- [ ] **Sequenceable, `period_column:`:** a row numbered before a switch to a custom `period_column:` (NULL there)
|
|
128
|
+
formats (`formatted_<field>`, `sequenceable_period_time`) in its `created_at` period instead of today's. (GRAPH-1)
|
|
129
|
+
- [ ] **Sluggable, `history: true` + `scope:`:** moving a record into a scope where another record's friendly_id history
|
|
130
|
+
holds its slug regenerates the slug (it raised `RecordNotUnique` or took over the old URL). (GRAPH-5)
|
|
131
|
+
- [ ] **Duplicable:** a child or has_one target marked for destruction (`_destroy`, `mark_for_destruction`) is no longer
|
|
132
|
+
copied. (GRAPH-6)
|
|
133
|
+
- [ ] **Legacy aliases:** a racing first reference to a top-level alias (`ConcernsOnRails::Sluggable`) no longer prints
|
|
134
|
+
"already initialized constant". (GRAPH-4)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- **Lockable**: `register_failed_attempt!`, `lock_access!`, `unlock_by_token`, the lapsed-lock clear and the lost-claim
|
|
140
|
+
adoption read no longer raise `ArgumentError` on a composite primary key (Rails 7.1+); every site addresses its row
|
|
141
|
+
through `Support::PrimaryKey`. (DATA-1)
|
|
142
|
+
- **Tokenizable**: `consume_<field>` no longer raises `ArgumentError` on a composite primary key. (DATA-1)
|
|
143
|
+
- **Lockable**: `register_failed_attempt!` on a `readonly!` (or destroyed) record raises `ReadOnlyRecord` /
|
|
144
|
+
`ActiveRecordError` before incrementing; it used to increment in SQL and then raise from `lock_access!`, leaving the
|
|
145
|
+
row at max attempts and unlocked. (DATA-4)
|
|
146
|
+
- **Tokenizable / Lockable**: `authenticate_by_<field>` / `consume_<field>` and `unlock_by_token` answer `nil`, without
|
|
147
|
+
a query, for a token holding a NUL byte (`?token=%00`); on PostgreSQL they raised (the driver refuses NUL in a bound
|
|
148
|
+
parameter). Uses `Support::ScalarParam.nul_free?` like Filterable. (DATA-3)
|
|
149
|
+
- **Encryptable**: a per-field `key:` that resolves to nil or `""` (a Proc over a missing credential, an explicit
|
|
150
|
+
`key: nil`, an eagerly nil `ENV[...]`) no longer falls back to the global key. It raises `MissingKeyError` naming
|
|
151
|
+
the field on write, read and blind-index lookup/refresh, or stays plaintext under `on_missing_key: :passthrough`;
|
|
152
|
+
`key:` must be a String or callable (`ArgumentError` otherwise). It used to seal a key_id-0 envelope under the
|
|
153
|
+
global key that the field key could not decrypt later. (DATA-2)
|
|
154
|
+
- **Authorizable**: the default denial `message:` follows the status (404 "Resource not found", 401 "Unauthorized",
|
|
155
|
+
else "Forbidden"), so a 404 denial is no longer distinguishable from ErrorHandleable's own 404. (HTTP-1)
|
|
156
|
+
- **Authorizable**: `require_role` flattens Array arguments (`require_role %i[admin editor]` used to deny everyone) and
|
|
157
|
+
refuses an empty list or a nil/blank role at class load. (HTTP-2)
|
|
158
|
+
- **SecureHeadable**: the `:hsts` preset is skipped under `config.force_ssl`, so it no longer overrides Rails' own HSTS
|
|
159
|
+
header. (HTTP-6)
|
|
160
|
+
- **SecureHeadable**: header names compare case-insensitively, so a lower-case custom pair beside a preset (or Rails'
|
|
161
|
+
default `X-Frame-Options` on Rails < 7.1) is one header, not two conflicting ones. (HTTP-7)
|
|
162
|
+
- **SoftDeletable**: `really_delete!`, `is_really_deleted?` and the trashed-row purge guard no longer raise on a
|
|
163
|
+
composite primary key. (STATE-3)
|
|
164
|
+
- **SoftDeletable**: `soft_delete!`/`restore!` read the SAVED stamp instead of the unsaved in-memory one; they used to
|
|
165
|
+
return `true` while writing nothing (no hooks, no cascade) after the stamp was assigned unsaved. `restore!` cascades
|
|
166
|
+
by the saved stamp. (STATE-12)
|
|
167
|
+
- **Sortable**: `reposition!` works on a composite primary key (Rails 7.1+). Ids are `[shop_id, id]` tuples (a record
|
|
168
|
+
is accepted too), cast per column and written one UPDATE each inside the transaction; a malformed tuple raises
|
|
169
|
+
`ArgumentError` before any write. A simple key keeps the single CASE UPDATE. It raised `TypeError: can't quote
|
|
170
|
+
Array`. (GRAPH-3)
|
|
171
|
+
- **CounterCacheable**: a composite-key `belongs_to` is refused at the macro (see Upgrading); it was accepted and never
|
|
172
|
+
counted. (GRAPH-2)
|
|
173
|
+
- **Expirable**: `time_until_expiry` no longer raises `TypeError` on a `date` column with a future date. It agrees with
|
|
174
|
+
`expired?` (0 when expired, today counts, in `Time.zone`), otherwise measures to the start of the date in
|
|
175
|
+
`Time.zone`, and is clamped at 0. Also fixes today's date in a zone ahead of UTC answering a positive duration
|
|
176
|
+
while `expired?` was true. (STATE-1)
|
|
177
|
+
- **Expirable**: `expire!` / `expire_in!` on a `date` column fire `before/after_expire` exactly when the written value
|
|
178
|
+
makes the record `expired?`, whatever the process TZ (`Date#to_time` used `ENV["TZ"]` and skipped the hooks for
|
|
179
|
+
hours a day). A blank time (`nil`, `""`, `false`) for `expire!`/`expire_all` is cast through the column like a given
|
|
180
|
+
time (today's `Date` on a `date` column; `expire_all(nil)` writes it). (STATE-2)
|
|
181
|
+
- **Expirable**: `expire_in!(nil)` and `extend_expiry!(by: nil)` raise the concern's `ArgumentError`
|
|
182
|
+
(shared `expirable_check_duration!`). (STATE-10)
|
|
183
|
+
- **Publishable**: `unpublish!` on a draft is idempotent like `publish!`; `unpublish_all` already skipped such rows, so
|
|
184
|
+
verb and batch now agree. (STATE-5)
|
|
185
|
+
- **Anonymizable**: the erasing UPDATE's result is honoured; a row deleted (or unwritable under optimistic locking)
|
|
186
|
+
between load and UPDATE is not counted, and `anonymize!` returns `false` instead of raising from the reload.
|
|
187
|
+
(STATE-9)
|
|
188
|
+
- **HookedWrite** (every hooked verb: `publish!`, `activate!`, `expire!`, `soft_delete!`, Stateable `<event>!`, ...): a
|
|
189
|
+
veto on a NEW record inside a caller's transaction left it `persisted?` with no id; the next `save` ran
|
|
190
|
+
`UPDATE ... WHERE id IS NULL`, returned `true` and wrote no row (`StaleObjectError` with `lock_version`). The
|
|
191
|
+
identity (`new_record?`, `destroyed?`, `previously_new_record?`, id) is now snapshotted and restored for new records
|
|
192
|
+
too. Rails 6.0 never showed the bug; 6.1 through 8.1 did. (STATE-11)
|
|
193
|
+
- **Storable / Auditable**: a PostgreSQL `jsonb` column and an `attribute :x, :json` override over a text column are
|
|
194
|
+
handed the Hash/Array instead of a JSON string scalar, so `where_<key>(v)` matches and `where_<key>(nil)` no longer
|
|
195
|
+
matches every row. One shared `Support::NativeJson.column?` backs both. (TRANS-1)
|
|
196
|
+
- **Normalizable**: no longer re-normalizes a later transform's output at `before_save`: `normalizable :title, with:
|
|
197
|
+
:titleize` plus `sanitizable :title, with: :strip, on: :write` stored `"A ≪ B"` for a validated `"A < B"`, and
|
|
198
|
+
an entity-decoding rule could store live markup in a `:strip` column. Every transform records what it wrote
|
|
199
|
+
(`Support::TransformBackstop.wrote`) and the settle step skips a field still holding it. (TRANS-2)
|
|
200
|
+
- **Normalizable / Sanitizable**: a save halted by a later `before_save` (or rolled back by `RecordNotUnique`), then
|
|
201
|
+
retried on the same record, no longer re-runs a non-idempotent rule on its own output (`"a!"` no longer becomes
|
|
202
|
+
`"a!!"`, `"Tom & Jerry"` no longer `"Tom &amp; Jerry"`), `update_attribute` saves included. The per-save
|
|
203
|
+
records are dropped after a successful save, kept across a rollback, and dup/clone no longer share them. (TRANS-3)
|
|
204
|
+
- **Taggable**: a tag ending with the start of a self-overlapping delimiter, or starting with its end (`["a-", "b"]`
|
|
205
|
+
under `"--"` read back as `["a", "-b"]`), is trimmed when cleaned; `tag_list=`/`add_tags` clean an Array item by item instead of joining and
|
|
206
|
+
re-splitting. (TRANS-4)
|
|
207
|
+
- **Monetizable**: `price = "1e18"` on an integer cents column casts to `nil` (checked against the column type's own
|
|
208
|
+
range) instead of a value whose save raised `ActiveModel::RangeError`. (TRANS-S3)
|
|
209
|
+
- **Paginatable / Includable**: `paginate_by` and `includable` re-declared in a subclass keep every omitted option
|
|
210
|
+
(an UNSET sentinel, as `cursor_paginate_by`); `paginate_by style:` re-derives both param paths, explicit
|
|
211
|
+
`page_param:`/`per_page_param:` still win; `includable` `fields:`/`default:`/`strategy:` inherit when omitted
|
|
212
|
+
(`default: nil` / `fields: {}` clear them). (PARAMS-2)
|
|
213
|
+
- **Paginatable**: a grouped relation with a `HAVING` on a select alias no longer raises `StatementInvalid`
|
|
214
|
+
(SQLite/MySQL); it is counted as a subquery that keeps its select list. A grouped relation with no `HAVING` or no
|
|
215
|
+
select list keeps the plain `COUNT ... GROUP BY`. (PARAMS-3)
|
|
216
|
+
- **CursorPaginatable**: `predicate: :row` with mixed directions is refused at the macro (see Upgrading); page 1
|
|
217
|
+
minted a cursor and every page-2 request answered 500. (PARAMS-4)
|
|
218
|
+
- **CursorPaginatable**: on SQLite a row whose String ordering value holds a NUL byte no longer ends the walk with a
|
|
219
|
+
400 on its own cursor; the boundary goes into the keyset WHERE as `CAST(X'<hex>' AS TEXT)`, which assumes a UTF-8
|
|
220
|
+
SQLite database (Rails' default). MySQL already accepted it; PostgreSQL still answers `InvalidCursor`. (PARAMS-5)
|
|
221
|
+
- **Paginatable / CursorPaginatable**: `page` / `per_page` are read as exact whole numbers through
|
|
222
|
+
`Support::NumericOperand` instead of `String#to_i` (`?per_page=1e3` was 1, `?per_page=10abc` was 10); a non-whole
|
|
223
|
+
JSON Float falls back too. (PARAMS-7)
|
|
224
|
+
- **Filterable**: `?<field>_null=` reads only an explicit true/false spelling; any other non-blank value matches
|
|
225
|
+
nothing (see Upgrading). (PARAMS-6)
|
|
226
|
+
- **Filterable**: Encryptable-aware (`Support::EncryptedLookup.rule`), `alias_attribute` followed: equality and `in` use
|
|
227
|
+
the blind index, `not`/`not_in`/`gt`/`gte`/`lt`/`lte`/`contains`/`starts_with` fail closed. `?ssn_not=123` used to
|
|
228
|
+
return the excluded row and `=`/`in`/`contains` silently matched nothing. `null` keeps working on an encrypted
|
|
229
|
+
column. A nil member of an Array operand is dropped. (PARAMS-1)
|
|
230
|
+
- **Filterable**: a Float-column operand is no longer rounded to the nearest double and treated as exact;
|
|
231
|
+
`?score_gt=0.1000000000000000000001` keeps a stored 0.1. A literal the double keeps (`0.1`) stays exact. (PARAMS-8)
|
|
232
|
+
- **Filterable**: a `with:` lambda declared with a `type:` fails closed on an Array/Hash/Parameters value instead of
|
|
233
|
+
receiving a cast one (`?flag[]=0` was `true`). (PARAMS-S4)
|
|
234
|
+
- **Authorizable**: the default `message:` follows the denial status (404 "Resource not found", the shared
|
|
235
|
+
`Support::ErrorEnvelope::NOT_FOUND_MESSAGE`; 401 "Unauthorized"; else "Forbidden"), stored resolved at declaration so
|
|
236
|
+
the instrumentation payload carries it. `require_role` flattens its arguments and raises on an empty, nil or blank
|
|
237
|
+
role. (HTTP-1, HTTP-2)
|
|
238
|
+
- **ErrorHandleable**: the RecordNotDestroyed 422 no longer leaks the model class name and primary key. (HTTP-4)
|
|
239
|
+
- **Timezoneable**: `Vary: Time-Zone` / `Vary: Cookie` are appended (via `Support::VaryHeader`; `Vary: *` left alone)
|
|
240
|
+
even when the request carries no `Time-Zone` header; `vary: false` opts out. (HTTP-3)
|
|
241
|
+
- **Respondable / ErrorEnvelope**: the envelope and inline branches no longer log "Status code :unprocessable_entity is
|
|
242
|
+
deprecated" on Rack >= 3.1; `ErrorEnvelope.renderable_status` renames only when the running Rack dropped the old
|
|
243
|
+
name (Rack < 3.1 keeps it). (HTTP-5)
|
|
244
|
+
- **SecureHeadable**: `:hsts` yields to `force_ssl`; header names merge case-insensitively through
|
|
245
|
+
`SecureHeadable.merge_headers` (no more two conflicting `X-Frame-Options` lines on Rails < 7.1). (HTTP-6, HTTP-7)
|
|
246
|
+
- **SoftDeletable**: association `destroy_all` resets the association cache instead of `loaded!`-empty. (STATE-4)
|
|
247
|
+
- **BatchOps / SoftDeletable**: `BatchOps.full_rows(relation)` (used by `BatchOps.run` and the relation `destroy_all`)
|
|
248
|
+
adds the model's own star to a partial select; `limited_keys` keeps the own-table star first and drops only plain
|
|
249
|
+
own-table columns from a DISTINCT key subquery. (STATE-7)
|
|
250
|
+
- **Stateable**: an event named `transition` is refused at the macro (STATE-6); `transition_all` raises
|
|
251
|
+
`RecordNotSaved` for its own invalid record and propagates another record's `RecordInvalid` (STATE-8).
|
|
252
|
+
- **Sequenceable**: legacy rows (NULL custom `period_column:`) format in their `created_at` period; `period_time` /
|
|
253
|
+
`base_time` take the field. (GRAPH-1)
|
|
254
|
+
- **Sluggable**: a `history: true` + `scope:` move treats another record's history slug in the new scope as taken.
|
|
255
|
+
(GRAPH-5)
|
|
256
|
+
- **Duplicable**: records marked for destruction are skipped, loaded or not, has_one included. (GRAPH-6)
|
|
257
|
+
- **Legacy aliases**: `const_missing` resolution is serialized by a reentrant Monitor. (GRAPH-4)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
### Known issues (not fixed in this release)
|
|
261
|
+
- CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
|
|
262
|
+
mirrored counter/lock_version in memory.
|
|
263
|
+
- Aliasable: eager-loading an alias together with its source in one query loads each child twice.
|
|
264
|
+
- Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
|
|
265
|
+
cannot be `Marshal.dump`ed (`Rails.cache`).
|
|
266
|
+
- CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
|
|
267
|
+
its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
|
|
268
|
+
- Normalizable / Sanitizable: on an unvalidated (`update_attribute`) save that is halted and retried, with TWO
|
|
269
|
+
transforms on one column where the SECOND (not Normalizable) rewrites the first's output, Sanitizable's own record
|
|
270
|
+
holds only the first's output, so a validated retry re-sanitizes.
|
|
271
|
+
- Sluggable `history: true` + `scope:`: friendly_id's history finder ignores scope, so after a record moves scope its
|
|
272
|
+
stale history slug can still match `Model.where(scope).friendly.find(old_slug)`. This is stock friendly_id
|
|
273
|
+
behaviour; the gem does not change it. (GRAPH-5)
|
|
274
|
+
|
|
275
|
+
|
|
3
276
|
## 1.33.0 (2026-10-18)
|
|
4
277
|
|
|
5
278
|
A correctness release: it fixes the 66 bugs proven by the 2026-10 audit of 1.32.1. Some fixes change
|
data/README.md
CHANGED
|
@@ -327,6 +327,7 @@ Post.find("hello-world") # resolves by slug
|
|
|
327
327
|
- Clearing the source (`update!(title: nil)`, `""`, or a value that normalizes to nothing such as `"!!!"`) keeps the existing slug instead of replacing it with a random uuid; the uuid fallback only applies when there is no slug yet.
|
|
328
328
|
- **Order-independent source.** A sibling that transforms the source in `before_validation` — [Sanitizable](#-sanitizable) `on: :write`, [Normalizable](#-normalizable), your own callback — no longer loses to include order: a slug built earlier in the same save from the raw value (`"<b>Hello</b>"` → `"b-hello-b"`) is rebuilt at the start of validation, before any validator (`reserved_words:` too) or sibling `before_save` sees it. A slug sourced from a column generated in `before_create` ([Sequenceable](#-sequenceable)'s `into:`, a [Hashable](#-hashable) code) is built in the same `INSERT` instead of staying `nil` until a later save — with the slug column's own write-mode Sanitizable / Normalizable rules applied, since their callbacks have already run. An explicitly assigned slug still wins.
|
|
329
329
|
- Falls back to `to_s` if the configured source field doesn't respond.
|
|
330
|
+
- With `history: true` + `scope:`, moving a record into a scope where another record's friendly_id history holds its slug regenerates the slug. friendly_id's own history finder is not scope-aware, so a mover's stale history row can still match the old slug in the new scope's relation (stock friendly_id behaviour).
|
|
330
331
|
- Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
|
|
331
332
|
|
|
332
333
|
---
|
|
@@ -364,7 +365,7 @@ sortable_by :position, add_new_at: :top # new rows insert at the top (a
|
|
|
364
365
|
**Notes**
|
|
365
366
|
- The configured field must exist as a column; a direction other than `:asc` / `:desc` raises at declaration.
|
|
366
367
|
- `scope:` can't be an [Encryptable](#-encryptable) column (a Symbol — resolved as acts_as_list does, so `scope: :list` means `list_id` or the association's foreign key — an Array entry or a fixed-value Hash key): acts_as_list's scope query would compare ciphertext, match no row and leave every record alone in its list (each new one at position 1, `move_*` a no-op), so the declaration raises `ArgumentError`, in either order. A String SQL scope is not inspected.
|
|
367
|
-
- `reposition!` runs inside the current relation (`Task.where(list_id: 1)` — the same set acts_as_list's `scope:` would use), rejects ids outside it and duplicates before writing anything, coerces String ids from params, and on a descending list gives the first id the highest value. It bypasses acts_as_list callbacks by design (one `update_all`, no per-row shifting).
|
|
368
|
+
- `reposition!` runs inside the current relation (`Task.where(list_id: 1)` — the same set acts_as_list's `scope:` would use), rejects ids outside it and duplicates before writing anything, coerces String ids from params, and on a descending list gives the first id the highest value. It bypasses acts_as_list callbacks by design (one `update_all`, no per-row shifting). On a composite primary key (Rails 7.1+) the ids are `[shop_id, id]` tuples (a record is accepted too) and each row is written with its own UPDATE inside the transaction; a malformed tuple raises `ArgumentError` before any write.
|
|
368
369
|
|
|
369
370
|
---
|
|
370
371
|
|
|
@@ -384,7 +385,7 @@ article.published? # => false
|
|
|
384
385
|
article.publish!
|
|
385
386
|
article.published? # => true
|
|
386
387
|
article.publish! # already published (saved state): a no-op (true), keeps published_at, no hooks
|
|
387
|
-
article.unpublish!
|
|
388
|
+
article.unpublish! # a draft (blank saved column): a no-op (true), no hooks; a scheduled row is still cleared
|
|
388
389
|
|
|
389
390
|
Article.published # WHERE published_at <= NOW()
|
|
390
391
|
Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
|
|
@@ -484,6 +485,8 @@ Passing `true` for a scope- or accessor-name affix means "use the configured fie
|
|
|
484
485
|
Unrelated to all three: Searchable's `match: :prefix` is a LIKE-match mode (`term%`), not a
|
|
485
486
|
naming affix.
|
|
486
487
|
|
|
488
|
+
`unpublish!` mirrors `publish!`: on a stored record with no unsaved change to the column and a blank saved value (a draft; `false`/`NULL` on a boolean) it is a no-op — `true`, nothing written, no `before/after_unpublish` (other pending changes are saved); a published or scheduled row still writes with the hooks.
|
|
489
|
+
|
|
487
490
|
**Lifecycle hooks** — override `before_publish` / `after_publish` / `before_unpublish` /
|
|
488
491
|
`after_unpublish`. They and the write share their own savepoint, so a hook that raises — or
|
|
489
492
|
vetoes with `raise ActiveRecord::Rollback` — undoes the write: `publish!` / `unpublish!` /
|
|
@@ -518,6 +521,8 @@ user.soft_delete!
|
|
|
518
521
|
user.deleted? # => true
|
|
519
522
|
user.restore!
|
|
520
523
|
user.deleted? # => false
|
|
524
|
+
# soft_delete!/restore! are no-ops only on the SAVED state (stored, stamp unchanged, saved stamp already in the target
|
|
525
|
+
# state; other pending changes are saved); an unsaved stamp assignment is written, with hooks and cascade
|
|
521
526
|
|
|
522
527
|
user.really_delete! # bypasses callbacks, hard deletes from DB; the instance is then destroyed? (not persisted?)
|
|
523
528
|
```
|
|
@@ -993,7 +998,7 @@ User.find_by(email: User.normalize(:email, params[:email]))
|
|
|
993
998
|
| `:url` | strip, default scheme to `https://` (`host:port` counts as schemeless; a protocol-relative `//CDN.example.com/x` becomes `https://cdn.example.com/x`), lowercase scheme + host, keep userinfo/path/query, drop a redundant default port. Only `http`/`https` are canonicalized — any other scheme (`mailto:`, `tel:`, `javascript:`, `data:`) and unparseable input come back stripped for your format validator to reject |
|
|
994
999
|
|
|
995
1000
|
**Notes**
|
|
996
|
-
- Runs in `before_validation`, so DB constraints and AR validations see the normalized value — with a `before_save` backstop for saves that skip validation (`update_attribute`, `save(validate: false)`); `update_column(s)`/`update_all` bypass it. A rule runs once per save, even a non-idempotent Proc under `save if valid?` (the second validation skips a field still holding what the first left, also after a Sanitizable `on: :write` rule rewrote it; `reload` forgets the earlier validation). The backstop is prepended to the save callbacks, so a sibling's `before_save` (Addressable's fingerprint) sees the normalized value whichever concern was included first; Encryptable's blind index and Auditable's entry run later still, at the write boundary (`Support::WriteBoundary`). It is one shared dispatcher with the Sanitizable / Taggable / Addressable backstops, run in their `before_validation` order, so a save that skips validation stores the same value as a validated one when two of them transform the same column.
|
|
1001
|
+
- Runs in `before_validation`, so DB constraints and AR validations see the normalized value — with a `before_save` backstop for saves that skip validation (`update_attribute`, `save(validate: false)`); `update_column(s)`/`update_all` bypass it. A rule runs once per save, even a non-idempotent Proc under `save if valid?` (the second validation skips a field still holding what the first left, also after a Sanitizable `on: :write` rule rewrote it; `reload` forgets the earlier validation) and also when a save is halted or rolled back and retried on the same record. The `before_save` step never re-normalizes a sibling transform's output (Sanitizable `on: :write`, Taggable, Addressable): it normalizes only a field no transform handled or one rewritten by non-transform code (a slug built in a validate step), so include Normalizable AFTER another transform to normalize that transform's output. The backstop is prepended to the save callbacks, so a sibling's `before_save` (Addressable's fingerprint) sees the normalized value whichever concern was included first; Encryptable's blind index and Auditable's entry run later still, at the write boundary (`Support::WriteBoundary`). It is one shared dispatcher with the Sanitizable / Taggable / Addressable backstops, run in their `before_validation` order, so a save that skips validation stores the same value as a validated one when two of them transform the same column.
|
|
997
1002
|
- `with:` takes a preset, a Proc, or an Array of them (applied in order); every entry is validated at class load.
|
|
998
1003
|
- `nil` values are skipped — no `nil → ""` coercion (use `:nullify_blank` for the opposite direction).
|
|
999
1004
|
- Preset normalizers pass non-string values through unchanged.
|
|
@@ -1195,7 +1200,7 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
|
|
|
1195
1200
|
| `start_at:` | `1` | First value when the scope/period has no rows yet. |
|
|
1196
1201
|
| `scope:` | `nil` | Column (or array of columns) the counter is scoped to — e.g. one sequence per `account_id`. |
|
|
1197
1202
|
| `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` — restart numbering each period (needs `created_at`, or your `period_column:`). |
|
|
1198
|
-
| `period_column:` | `:created_at` | Datetime column the `reset:` period is cut from. With `assign: :manual` set it to a stamp such as `:finalized_at` so a draft numbers into the period it is **finalized** in (`assign_<field>!` stamps it when blank); with the default a late-finalized draft numbers into its creation period. Not `:updated_at`. Declare it on the class that declares the format (or on an `assign:`-only re-declaration, which inherits it): a subclass restating the same format with a different `period_column:` is refused, since both series would print the same numbers. |
|
|
1203
|
+
| `period_column:` | `:created_at` | Datetime column the `reset:` period is cut from. With `assign: :manual` set it to a stamp such as `:finalized_at` so a draft numbers into the period it is **finalized** in (`assign_<field>!` stamps it when blank); with the default a late-finalized draft numbers into its creation period. Not `:updated_at`. Declare it on the class that declares the format (or on an `assign:`-only re-declaration, which inherits it): a subclass restating the same format with a different `period_column:` is refused, since both series would print the same numbers. A row numbered before a switch (NULL there) also displays (`formatted_<field>`, `sequenceable_period_time`) in its `created_at` period. |
|
|
1199
1204
|
| `time_zone:` | app zone | Zone the `reset:` periods are cut in (a zone name as a String or Symbol, or an `ActiveSupport::TimeZone`). Defaults to `config.time_zone` (`Time.zone_default`), else UTC — never the per-request `Time.zone`. Unknown names raise `ArgumentError`. |
|
|
1200
1205
|
| `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. To render the period, read `record.sequenceable_period_time(:field)` (the fixed-zone instant), not `created_at`. |
|
|
1201
1206
|
| `assign:` | `:create` | `:create` numbers every record in `before_create`; `:manual` leaves the column NULL until `assign_<field>!` is called — for invoices that get their number when finalized, not when drafted. Re-declaring the field (same class or an STI subclass) switches the mode; the last declaration wins. Pass only `assign:` to keep the rest of the config (see the notes). |
|
|
@@ -1287,7 +1292,7 @@ declaration: a base-class `transition_all` skips the rows of a subclass that re-
|
|
|
1287
1292
|
`stateable_by` without the event (it used to abort with `NoMethodError`) and transitions the rows a
|
|
1288
1293
|
subclass's wider `from:` accepts (the base's `from:` used to filter them out). With `lock: true` each record's guard is checked under its row
|
|
1289
1294
|
lock, so a row another process moved after the batch loaded it is skipped like any other rejected row
|
|
1290
|
-
(it used to raise `InvalidTransition` and roll the whole batch back); a single `<event>!` still raises.
|
|
1295
|
+
(it used to raise `InvalidTransition` and roll the whole batch back); a single `<event>!` still raises. A record that fails validation raises `ActiveRecord::RecordNotSaved` (carrying the record; the batch rolls back) — not `RecordInvalid`, which only a single `<event>!` raises (another record's failed `save!` inside a hook still propagates as `RecordInvalid`).
|
|
1291
1296
|
|
|
1292
1297
|
**Timestamps and per-event hooks**
|
|
1293
1298
|
|
|
@@ -1337,6 +1342,7 @@ stateable_by :state, states: %i[open closed], prefix: true
|
|
|
1337
1342
|
- Re-declaring without `default:` keeps the earlier default while it is still one of the declared states; otherwise (or with an explicit `default: nil`) new records fall back to the column's database default.
|
|
1338
1343
|
- Re-declaring replaces the generated methods too: the states, events and scopes the new declaration no longer lists are removed (hidden in an STI subclass, which keeps its parent's untouched), so a subclass with `states: %i[open closed]` no longer answers its parent's `archive!` or `.draft`. A method of such a name that the class defined itself is left alone.
|
|
1339
1344
|
- No persistence of transition history; combine with `Publishable` / `Schedulable` for time-based state tracking.
|
|
1345
|
+
- An event whose (affixed) name is `transition` is refused at the macro (`ArgumentError`) — its `transition!` would collide with the generic `before_/after_transition` hooks; rename the event or use `prefix:`/`suffix:`.
|
|
1340
1346
|
|
|
1341
1347
|
---
|
|
1342
1348
|
|
|
@@ -1463,7 +1469,7 @@ Article.normalize_tags! # repair rows written around the
|
|
|
1463
1469
|
|
|
1464
1470
|
| Option | Default | Purpose |
|
|
1465
1471
|
|--------------|---------|------------------------------------------------------------------|
|
|
1466
|
-
| `delimiter:` | `","` | Character joining the stored tags (a tag must not contain it; an empty delimiter raises). |
|
|
1472
|
+
| `delimiter:` | `","` | Character joining the stored tags (a tag must not contain it; an empty delimiter raises). A tag cannot start or end with part of a self-overlapping delimiter (`"--"`, `"||"`, `" \| "`): the part is dropped when the tag is cleaned (`"a-"` becomes `"a"` under `"--"`). |
|
|
1467
1473
|
| `downcase:` | `false` | Case-fold tags on write so matching is case-insensitive. |
|
|
1468
1474
|
|
|
1469
1475
|
**Notes**
|
|
@@ -1599,7 +1605,7 @@ product.formatted_price # => "$19.99"
|
|
|
1599
1605
|
| Method | Returns |
|
|
1600
1606
|
|-------------------|-----------------------------------------------|
|
|
1601
1607
|
| `price` | the amount as a `BigDecimal` (cents ÷ 100) |
|
|
1602
|
-
| `price=` | assign in major units; rounded to whole cents. Strings are read as plain decimals (`"19.99"`) or in the field's own display format (`"$1,234.50"`, `"€1.234,50"` with `separator: ","`), so `formatted_price` round-trips. The unit is only recognized at the start or end; in a `delimiter: "."` field with another separator, `"1.234"` means 1234 (like `"€1.234"`), but a leading `0` group is never grouping (`"0.500"` is 0.5 there, and `"0,500"` in a `","` field is `nil`, not 500). Garbage, NaN, Infinity and oversized input (over 64 characters, a 3-digit exponent, or ≥ 10^24 — a parse-cost bound, far above a `bigint` column's ~9.2e18 subunits,
|
|
1608
|
+
| `price=` | assign in major units; rounded to whole cents. Strings are read as plain decimals (`"19.99"`) or in the field's own display format (`"$1,234.50"`, `"€1.234,50"` with `separator: ","`), so `formatted_price` round-trips. The unit is only recognized at the start or end; in a `delimiter: "."` field with another separator, `"1.234"` means 1234 (like `"€1.234"`), but a leading `0` group is never grouping (`"0.500"` is 0.5 there, and `"0,500"` in a `","` field is `nil`, not 500). Garbage, NaN, Infinity and oversized input (over 64 characters, a 3-digit exponent, or ≥ 10^24 — a parse-cost bound, far above a `bigint` column's ~9.2e18 subunits, and an amount past the cents column's integer range, checked against its type — a 4-byte column overflows at about 21.4 million units) become `nil` |
|
|
1603
1609
|
| `formatted_price` | a display string (`"$1,234.56"`); accepts per-call overrides: `formatted_price(unit: "€", delimiter: ".", separator: ",")` |
|
|
1604
1610
|
|
|
1605
1611
|
**Aggregates** — class methods that follow the current scope, exact and float-free:
|
|
@@ -1809,7 +1815,7 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
|
|
|
1809
1815
|
**Options** (`counter_cacheable_by association, …`, repeatable — re-declaring the same association + `count:` replaces that rule, e.g. an STI subclass narrowing it with `if:`): `count:` (the parent column; default `"<table_name>_count"`), `if:` (a callable evaluated against the record — counts only when truthy; the previous state is reconstructed for updates), `touch:` (`false`; also bump the parent's `updated_at`).
|
|
1810
1816
|
|
|
1811
1817
|
**Notes**
|
|
1812
|
-
- The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
|
|
1818
|
+
- The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations and composite-key associations (a composite `foreign_key:`, association primary key or `query_constraints:`, Rails 7.1+) are not supported: the macro raises `ArgumentError` (a model-level `query_constraints` must be declared before `counter_cacheable_by`).
|
|
1813
1819
|
- Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
|
|
1814
1820
|
- Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. Bare, it rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline. On an STI table every class's rows are tallied under that class's own rule for the column, so a repair from any class of the tree matches the live counts (types are resolved without instantiating a row; a stored type that no longer resolves counts under the base class's rule). With `parents:` it zeroes and re-tallies only those parents (O(their children)) in one transaction that locks those rows before tallying, so repairing one imported post is safe on the request path.
|
|
1815
1821
|
- Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
|
|
@@ -1838,7 +1844,7 @@ Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
|
|
|
1838
1844
|
Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
1839
1845
|
```
|
|
1840
1846
|
|
|
1841
|
-
**Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`; a `:datetime` field behaves like a datetime column on the model: under `time_zone_aware_attributes` zone-less input is read in `Time.zone` and it reads back as a `TimeWithZone`, while the plaintext stays UTC ISO8601), `key:` (per-field override; a String or
|
|
1847
|
+
**Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`; a `:datetime` field behaves like a datetime column on the model: under `time_zone_aware_attributes` zone-less input is read in `Time.zone` and it reads back as a `TimeWithZone`, while the plaintext stays UTC ISO8601), `key:` (per-field override; a String or a callable, anything else raises `ArgumentError`. A declared key that resolves to nil or `""` — a Proc over a missing credential, an explicit `key: nil`, an unset `ENV[...]` — is a missing key: `MissingKeyError` naming the field, or plaintext under `on_missing_key: :passthrough`; it never falls back to the global key, which only an omitted `key:` uses), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query; the digest is of the canonical plaintext, so a `:datetime` is found by any rendering of the instant in any `Time.zone` and a String finds a typed field — rows indexed before still match the lookup that found them, and `:datetime` fields should be [reindexed once](docs/concerns/encryptable.md#blind-index-of-a-typed-field)).
|
|
1842
1848
|
|
|
1843
1849
|
**Key rotation** — bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
|
|
1844
1850
|
|
|
@@ -1860,6 +1866,7 @@ On the model itself both sweep the **whole table**, bypassing `default_scope`
|
|
|
1860
1866
|
Reads pick the key by the envelope's id, so old and new rows coexist; `find_by_<field>` / `where_<field>` match blind-index digests under the current **and** previous keys during the window. Per-field `key:` fields sit outside rotation.
|
|
1861
1867
|
|
|
1862
1868
|
**Notes**
|
|
1869
|
+
- [Filterable](#-filterable) is Encryptable-aware (`alias_attribute` followed): on an encrypted field equality and `in` (`?ssn=`, `?ssn_in=`) go through the blind index, and every other operator (`not`, `not_in`, `gt`/`gte`/`lt`/`lte`, `contains`, `starts_with`) fails closed (matches nothing); a field without a blind index fails closed for every operator but `null`.
|
|
1863
1870
|
- Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
|
|
1864
1871
|
- Rotation is gem-level (`key_id` / `previous_keys`); `reencrypt_all!` streams with `find_each` and rewrites each row with one UPDATE — no validations/callbacks (only the ciphertext changes), no `lock_version` bump (so records open in edit forms don't go stale), guarded on the ciphertext it read so a concurrent write is never reverted, and skipping any field with an unsaved change. A row whose key id is no longer configured raises `DecryptionError` naming the id.
|
|
1865
1872
|
- Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need Rails-managed key infrastructure (KMS, per-record keys) or deterministic encryption.
|
|
@@ -1933,6 +1940,7 @@ copy = invoice.duplicate!(only: []) # shallow copy — attri
|
|
|
1933
1940
|
- The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
|
|
1934
1941
|
- `only:` / `except:` on `duplicate` / `duplicate!` pick which of the declared associations this particular copy carries ("Duplicate with line items?" checkbox); names outside the allow-list raise. They are reserved keys — pass overrides for attributes literally named `only`/`except` as a braced Hash.
|
|
1935
1942
|
- Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
|
|
1943
|
+
- A child (or has_one target) marked for destruction in memory (nested attributes `_destroy`, `mark_for_destruction`) is not copied, so a "save as copy" flow does not resurrect a just-removed child.
|
|
1936
1944
|
- Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
|
|
1937
1945
|
|
|
1938
1946
|
---
|
|
@@ -1988,6 +1996,8 @@ render_success(data: paginated(result.hits, total: result.total_hits), meta: pag
|
|
|
1988
1996
|
| `?page=` | `1` | Page numbers below 1 are clamped to 1 |
|
|
1989
1997
|
| `?per_page=` | `25` | Capped at `max_per_page` (default 200) |
|
|
1990
1998
|
|
|
1999
|
+
`?page=` and `?per_page=` are read as an exact whole number (`?per_page=1e3` is 1000, then capped; `?page=2e0` is 2); anything else — a fraction (`2.9`), trailing garbage (`10abc`), a value beyond 100 digits — is the default (page 1 / the default `per_page`), never its leading digits. A grouped relation counts its groups, a `HAVING` on a select alias included (it is counted as a subquery that keeps its select list). Re-declaring `paginate_by` (e.g. in a subclass) changes only the options passed — every omitted one (`max_per_page:`, `link_header:`, `window:`, `style:`, the param paths) is inherited; `style:` re-derives both param paths, and an explicit `page_param:`/`per_page_param:` still wins.
|
|
2000
|
+
|
|
1991
2001
|
Rename them, or speak JSON:API — the `Link` header URLs follow whatever you pick:
|
|
1992
2002
|
|
|
1993
2003
|
```ruby
|
|
@@ -2062,7 +2072,7 @@ end
|
|
|
2062
2072
|
| Param | Default | Notes |
|
|
2063
2073
|
|--------------|---------|----------------------------------------------------------|
|
|
2064
2074
|
| `?cursor=` | — | The opaque token from `X-Next-Cursor` (omit for page 1) |
|
|
2065
|
-
| `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the configured cap, but never the absolute 1,000,000 ceiling it shares with Paginatable) |
|
|
2075
|
+
| `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the configured cap, but never the absolute 1,000,000 ceiling it shares with Paginatable); read as an exact whole number, anything else is the default |
|
|
2066
2076
|
| `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
|
|
2067
2077
|
|
|
2068
2078
|
**Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`. Plus an RFC 8288 `Link` header: `rel="next"` carries the next-cursor URL, `rel="prev"` the prev-cursor URL (bidirectional), `rel="first"` the current URL with the cursor dropped (once a cursor is in play); `per_page` and the order preset are preserved. `cursor_paginate_by link_header: false` turns it off.
|
|
@@ -2071,7 +2081,7 @@ end
|
|
|
2071
2081
|
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params), must be **selected by the relation, unaliased**, and may be nullable: a column the schema allows NULL in paginates with its **NULLs last** in either direction on every adapter, returning every row exactly once with no extra queries. PostgreSQL uses `NULLS LAST`; other adapters use a `CASE WHEN col IS NULL` sort key, and the keyset WHERE uses the OR-expansion. NOT NULL columns keep the plain, index-friendly SQL, so prefer them on large tables. Boundary values are the stored column values (`read_attribute`), so an overridden attribute reader cannot derail the walk (a binary one — e.g. a `binary(16)` UUID primary key — travels as Base64; a `time` column's is its stored time of day, `"10:30:15.500000"`, which every Rails line reads back exactly); a boundary column the relation did not select raises `ActiveModel::MissingAttributeError`, and a stored non-NULL value that its attribute type casts to nil (an unparseable datetime string) raises `ArgumentError` — neither is minted as a NULL boundary. A select alias that shadows an ordering column (`select(:id, "UPPER(name) AS name")`) is not detected: the cursor would carry the alias's value while the WHERE compares the column, repeating or skipping rows — alias under another name instead. Re-declaring `cursor_paginate_by` (e.g. in a subclass) changes only the options passed — the rest, `signed:` included, are inherited.
|
|
2072
2082
|
- Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). Unsigned by default: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection); a value the column cannot hold — a timestamp outside years 0001..9999, a String that is invalid UTF-8 or carries a NUL byte (accepted on MySQL, which stores and escapes one), or is a number beyond a decimal's precision or too long/wide to read (bounded like Filterable's operands), included — is the same 400, never a database error and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary. **`signed: true`** (or a String key, or a callable for rotating keys) appends a URL-safe HMAC-SHA256 to every cursor and rejects tampered or unsigned tokens with the same 400, so clients can no longer hand-craft positions at all; `true` uses `Rails.application.secret_key_base`. Turning it on invalidates in-flight cursors (clients restart from page one).
|
|
2073
2083
|
- `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
|
|
2074
|
-
- Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
|
|
2084
|
+
- Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy). `:row` requires uniform directions, validated at declaration: `predicate: :row` with a mixed-direction `order:` or `order_presets:` entry raises `ArgumentError` at class load (a per-call `cursor_paginated(order:)` with mixed directions raises before page 1) — use `:auto` for a mixed order. A NUL-bearing String ordering value on SQLite is paged with the boundary cast from its hex bytes as TEXT, which assumes a UTF-8 SQLite database (Rails' default).
|
|
2075
2085
|
- Use Paginatable when you need page numbers and totals.
|
|
2076
2086
|
|
|
2077
2087
|
---
|
|
@@ -2104,7 +2114,7 @@ accepted as a suffix `?price_gte=10` or in bracket form `?price[gte]=10&price[lt
|
|
|
2104
2114
|
| `not` | `?status_not=draft` | `status != 'draft'` |
|
|
2105
2115
|
| `gt` `gte` `lt` `lte` | `?price_gte=10` | `price >= 10` (cast through the column type) |
|
|
2106
2116
|
| `in` `not_in` | `?status_in=a,b` or `?status_in[]=a` | `status IN ('a','b')` |
|
|
2107
|
-
| `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`) |
|
|
2117
|
+
| `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`); reads true/false (also 1/0, t/f, yes/no, case-insensitive); any other value matches nothing |
|
|
2108
2118
|
| `contains` `starts_with` | `?title_contains=rails` | `title LIKE '%rails%'` (wildcards escaped; ILIKE on PostgreSQL; string/text columns only) |
|
|
2109
2119
|
|
|
2110
2120
|
Comparison values are cast through the column's own type, or through `type:` (any ActiveModel type
|
|
@@ -2121,7 +2131,7 @@ are skipped and unknown operators / non-scalar values ignored. A `gt`/`gte`/`lt`
|
|
|
2121
2131
|
cannot represent (`?price_gte=abc`) matches **nothing** rather than silently comparing against `0` —
|
|
2122
2132
|
nothing raises at request time. So does any operand carrying a NUL byte (`?title_gte=a%00b`,
|
|
2123
2133
|
`?title_contains=%00`, `?title=a%00b`, a NUL member of an `in` list; binary columns excepted): it used to
|
|
2124
|
-
cut the SQL short, a `StatementInvalid` 500. For strict, validated contracts reach for `Permittable`.
|
|
2134
|
+
cut the SQL short, a `StatementInvalid` 500. A `with:` lambda declared with a `type:` never receives an Array/Hash/Parameters value: it fails the filter closed for every `type:`, the lambda is not called. A Float column's operand is compared exactly too: a decimal literal the double does not read back as (`0.1000000000000000000001`) equals nothing and compares as the largest double at or below it (`?score_gt=` keeps a stored `0.1`). On an [Encryptable](#-encryptable) column equality and `in` use the blind index and every other operator fails closed (see Encryptable). For strict, validated contracts reach for `Permittable`.
|
|
2125
2135
|
|
|
2126
2136
|
On an `enum` column a comparison operand is a **label** (`?status_gte=review` is `status >= 1`). On an
|
|
2127
2137
|
integer-backed enum anything else is read as a number, exactly like an integer column's operand, so a
|
|
@@ -2294,7 +2304,7 @@ end
|
|
|
2294
2304
|
| `record_invalid` | `ActiveRecord::RecordInvalid` | 422 | `errors.full_messages` |
|
|
2295
2305
|
| `validation_error` | `ActiveModel::ValidationError` | 422 | `model.errors.full_messages` |
|
|
2296
2306
|
| `record_not_saved` | `ActiveRecord::RecordNotSaved` | 422 | record errors, if any |
|
|
2297
|
-
| `record_not_destroyed` | `ActiveRecord::RecordNotDestroyed` | 422 | record errors, if any
|
|
2307
|
+
| `record_not_destroyed` | `ActiveRecord::RecordNotDestroyed` | 422 | record errors, if any (message is the generic "Resource could not be deleted") |
|
|
2298
2308
|
| `stale_object` | `ActiveRecord::StaleObjectError` | 409 | — |
|
|
2299
2309
|
| `record_not_unique` | `ActiveRecord::RecordNotUnique` | 409 | — |
|
|
2300
2310
|
| `foreign_key_violation` | `ActiveRecord::InvalidForeignKey` | 409 | — |
|
|
@@ -2403,6 +2413,7 @@ GET /articles?include=author,comments.author&fields[articles]=id,title&fields[au
|
|
|
2403
2413
|
- A path is kept only if **every** segment follows the allow-list tree (`comments.author` needs `comments: :author`); anything else is **silently dropped** — no error, no arbitrary eager-loading.
|
|
2404
2414
|
- `default:` applies only when `?include` is absent; `?include=` (blank) means "nothing" and is honoured. Defaults are validated against the allow-list at class load, stored frozen and `dup`ed per request.
|
|
2405
2415
|
- **A typo in an option name becomes an association.** Inline nested Hashes arrive as `**nested`, so any keyword the macro does not name is registered as an allow-listed association instead of raising: `feilds:` silently leaves `includable_fields` empty — i.e. no sparse-fieldset allow-list at all.
|
|
2416
|
+
- Re-declaring `includable` (e.g. in a subclass) changes only what it passes: the association list replaces the parent's, but `fields:`, `default:` and `strategy:` keep their inherited value when omitted (`default: nil` / `fields: {}` clear them). An inherited `default:` path the new list no longer contains is dropped with a one-line warning on stderr at class load.
|
|
2406
2417
|
- Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
|
|
2407
2418
|
- Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
|
|
2408
2419
|
|
|
@@ -2442,7 +2453,7 @@ end
|
|
|
2442
2453
|
| `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
|
|
2443
2454
|
| `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
|
|
2444
2455
|
| `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
|
|
2445
|
-
| `:hsts` | `Strict-Transport-Security: max-age=31536000; includeSubDomains` (no `preload` — opt in via a custom pair) |
|
|
2456
|
+
| `:hsts` | `Strict-Transport-Security: max-age=31536000; includeSubDomains` (no `preload` — opt in via a custom pair). Skipped automatically when `config.force_ssl` is on (Rails' own HSTS, from `config.ssl_options`, applies instead). |
|
|
2446
2457
|
| `:same_origin_opener` / `:same_origin_opener_allow_popups` | `Cross-Origin-Opener-Policy: same-origin` / `same-origin-allow-popups` |
|
|
2447
2458
|
| `:require_corp_embedder` | `Cross-Origin-Embedder-Policy: require-corp` |
|
|
2448
2459
|
| `:same_origin_resource` | `Cross-Origin-Resource-Policy: same-origin` |
|
|
@@ -2505,8 +2516,8 @@ The predicate runs via `instance_exec`, so `current_user` (and any helper) resol
|
|
|
2505
2516
|
|
|
2506
2517
|
| Method | Signature |
|
|
2507
2518
|
|---------------------------|--------------------------------------------------------------------------------------------|
|
|
2508
|
-
| `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message:
|
|
2509
|
-
| `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:, name:)` |
|
|
2519
|
+
| `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message: <derived from status>, name: nil, &block)` |
|
|
2520
|
+
| `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:, name:)` — roles may be given as one or several Arrays (flattened) |
|
|
2510
2521
|
| `skip_authorization` | `skip_authorization(only: %i[index show])` / `skip_authorization(except: %i[destroy])` / bare — exempt actions from every rule, inherited ones included; bare form exempts all |
|
|
2511
2522
|
| `authorized?` | `authorized?(action = action_name)` — evaluate the rules without rendering (for views / conditional UI) |
|
|
2512
2523
|
| `on_authorization_denied` | `on_authorization_denied(rule)` — override point; instruments `authorization_denied.concerns_on_rails` (call `super` to keep the event) |
|
|
@@ -2514,8 +2525,8 @@ The predicate runs via `instance_exec`, so `current_user` (and any helper) resol
|
|
|
2514
2525
|
**Notes**
|
|
2515
2526
|
- Rules run in declaration order; the first failing rule renders and halts.
|
|
2516
2527
|
- Every denial emits `authorization_denied.concerns_on_rails` with `controller`, `action`, `actor_id`, `actor_type`, `rule` (the `name:`), `status`, `message` — subscribe for audit logs or alerting on repeated denials. The actor is reduced to scalars on purpose: notification payloads aren't filtered by `config.filter_parameters`.
|
|
2517
|
-
- When `Respondable` is also included, denials delegate to `render_error` (envelope `{ success: false, error: { message:, code: "forbidden" } }`); otherwise the same envelope is rendered inline. The `code` follows `status:`: `"unauthorized"` for 401, `"not_found"` for 404, `"forbidden"` for 403 and anything else.
|
|
2518
|
-
- `only:` / `except:` are mutually exclusive (passing both raises `ArgumentError`); `authorize_by` requires a block and `require_role` requires at least one role.
|
|
2528
|
+
- When `Respondable` is also included, denials delegate to `render_error` (envelope `{ success: false, error: { message:, code: "forbidden" } }`); otherwise the same envelope is rendered inline. The `code` follows `status:`: `"unauthorized"` for 401, `"not_found"` for 404, `"forbidden"` for 403 and anything else. The default `message:` follows the status: `"Resource not found"` for 404 (ErrorHandleable's exact text), `"Unauthorized"` for 401, `"Forbidden"` otherwise; an explicit `message:` wins. A `status: :not_found` rule then answers exactly like ErrorHandleable's missing-record 404.
|
|
2529
|
+
- `only:` / `except:` are mutually exclusive (passing both raises `ArgumentError`); `authorize_by` requires a block and `require_role` requires at least one role (an empty list, or a nil/blank role such as an unset `ENV` lookup, raises at class load; Arrays are flattened).
|
|
2519
2530
|
- `skip_authorization` validates its arguments at class-load time, because every mistake there fails **open**: a nil `only:`/`except:`, or an empty/non-action `except:` (`[]`, `false`, `""` — what `except: Rails.env.production? && :destroy` collapses to), raises instead of exempting every action of the controller *and its subclasses*. Only the bare form grants a blanket skip. The skip is inherited and outranks rules a subclass declares afterwards; `skip_authorization only: []` switches it back off.
|
|
2520
2531
|
- **Non-goals**: no policy objects, no ability DSL, no resource inference — reach for [`pundit`](https://github.com/varvet/pundit) / [`cancancan`](https://github.com/CanCanCommunity/cancancan) when you outgrow a predicate per action.
|
|
2521
2532
|
|
|
@@ -2563,13 +2574,13 @@ class ApplicationController < ActionController::Base
|
|
|
2563
2574
|
timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
|
|
2564
2575
|
# timezoneable param: :tz, header: false, cookie: :time_zone
|
|
2565
2576
|
# timezoneable cookie: :time_zone, persist: true # ?time_zone=London sticks for a year
|
|
2566
|
-
# timezoneable response_header: true # X-Time-Zone: London
|
|
2577
|
+
# timezoneable response_header: true # X-Time-Zone: London
|
|
2567
2578
|
end
|
|
2568
2579
|
```
|
|
2569
2580
|
|
|
2570
2581
|
Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled) → `default` → the current `Time.zone`. Every value — the configured `available:` / `default:` **and** each request candidate — is resolved through `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be rejected at request time.
|
|
2571
2582
|
|
|
2572
|
-
**Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name), `persist:` (default `false`; `true` or a Hash of cookie options — writes a **param**-chosen zone into the `cookie:` so a settings link makes it stick; needs `cookie:`), `response_header:` (default `false`; `true` emits `X-Time-Zone`, or pass a header name
|
|
2583
|
+
**Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name), `persist:` (default `false`; `true` or a Hash of cookie options — writes a **param**-chosen zone into the `cookie:` so a settings link makes it stick; needs `cookie:`), `response_header:` (default `false`; `true` emits `X-Time-Zone`, or pass a header name), `vary:` (default `true`; appends `Vary: Time-Zone` whenever the header source is on and `Vary: Cookie` whenever the cookie source is on, independent of `response_header:`; `vary: false` opts out).
|
|
2573
2584
|
|
|
2574
2585
|
**Notes**
|
|
2575
2586
|
- An unknown `available:` / `default:` zone raises `ArgumentError` at declaration time (fail-fast on misconfiguration); so does `persist:` without `cookie:`.
|
|
@@ -2860,7 +2871,7 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2860
2871
|
bundle install # install dev dependencies
|
|
2861
2872
|
bundle exec rspec # run the test suite (3,420 examples)
|
|
2862
2873
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2863
|
-
gem install ./concerns_on_rails-1.
|
|
2874
|
+
gem install ./concerns_on_rails-1.34.0.gem # install locally
|
|
2864
2875
|
|
|
2865
2876
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2866
2877
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|