concerns_on_rails 1.28.2 → 1.28.4
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 +162 -0
- data/README.md +41 -8
- data/lib/concerns_on_rails/controllers/deprecatable.rb +15 -3
- data/lib/concerns_on_rails/controllers/filterable.rb +22 -4
- data/lib/concerns_on_rails/controllers/paginatable.rb +46 -4
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +53 -4
- data/lib/concerns_on_rails/models/aliasable.rb +9 -0
- data/lib/concerns_on_rails/models/anonymizable.rb +1 -1
- data/lib/concerns_on_rails/models/encryptable.rb +28 -5
- data/lib/concerns_on_rails/models/lockable.rb +9 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +151 -16
- data/lib/concerns_on_rails/models/stateable.rb +24 -3
- data/lib/concerns_on_rails/models/taggable.rb +9 -0
- data/lib/concerns_on_rails/support/column_guard.rb +38 -15
- data/lib/concerns_on_rails/support/encryptor.rb +23 -0
- data/lib/concerns_on_rails/support/error_envelope.rb +11 -2
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615b4ddcff3ebdffc7e32f618731510bf5e386fa015cb0a1ae958a4e6fb04d1c
|
|
4
|
+
data.tar.gz: 723b3ae2077f43fc5771947d2e610f011ca83ee8215b78fb0894a04400362220
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 352ff281ce000213b296a1b8ccae97478d6a6726644fb6bcf69c5f4e05d94c57e674f5cff59f3ed0ff8d90891c90b6ebc53affdec5f47ea64102ca88a3c40a0e
|
|
7
|
+
data.tar.gz: 595236a2464439aa26d10ed118fc5d307ca47022cac8e5c9cae6077419da39dc7a3fc2d085534bfb2d36bdf3625c99442054dc7fd5868be71b9034923aa66e11
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,167 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.4 (2026-09-16)
|
|
4
|
+
|
|
5
|
+
Eleven bug-fix PRs (#91–#101) from the audit of the shipped gem, released as a
|
|
6
|
+
patch: no new concerns, no new options, no migrations, no dependency changes.
|
|
7
|
+
Two close fail-open holes (WebhookVerifiable, Encryptable); the rest are
|
|
8
|
+
correctness fixes for behaviour the docs already promised. Every fix ships with
|
|
9
|
+
a regression spec that fails on 1.28.3. 1460 examples, 0 failures.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
- **Controllers::WebhookVerifiable**: verification could be skipped entirely,
|
|
13
|
+
leaving the action to run on an unverified — possibly forged — payload. Two
|
|
14
|
+
paths: `webhook_verification_failed` returned `nil` when there was no response
|
|
15
|
+
object to render into, which left the `before_action` chain unhalted; and
|
|
16
|
+
`webhook_rule_for_action` returned `nil` (read as "no rule applies, carry on")
|
|
17
|
+
when `action_name` was unresolvable or `""`. Both fail closed now — the first
|
|
18
|
+
raises, the second falls back to the catch-all rule, or to a lone declared
|
|
19
|
+
rule, and verifies. With several action-specific rules and no catch-all it
|
|
20
|
+
raises rather than verifying against an arbitrary provider's secret, which
|
|
21
|
+
would reject a valid delivery as "signature invalid". The render guard also
|
|
22
|
+
honours a `render_error` override on its own, so a controller supplying one
|
|
23
|
+
but no response object renders its rejection instead of raising. Mirrors the
|
|
24
|
+
fix Authorizable got in 1.22. A resolvable action simply not covered by any
|
|
25
|
+
rule still passes through untouched. (#92)
|
|
26
|
+
- **Models::Encryptable**: `<field>_ciphertext` — documented for "asserting no
|
|
27
|
+
plaintext is at rest" — returned the caller's **plaintext** whenever the value
|
|
28
|
+
had not round-tripped through the database (a new record, or any pending
|
|
29
|
+
assignment: exactly the state inside a `before_save`, a validator, or an
|
|
30
|
+
error-reporting path), so `log.info(user.ssn_ciphertext)` wrote the SSN
|
|
31
|
+
straight to the log. It returns `nil` in that state now. `<field>_encrypted?`
|
|
32
|
+
used a bare `.present?`, true for plaintext too; it now checks that what is
|
|
33
|
+
stored really is an encryption envelope, via the new
|
|
34
|
+
`Support::Encryptor.envelope?`. (#97)
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- **Models::Aliasable**: an aliased `belongs_to` carrying `counter_cache:`
|
|
38
|
+
double-counted. The alias copy kept the `:counter_cache` option, and because
|
|
39
|
+
the `#association` override maps the alias back to the same association
|
|
40
|
+
object, ActiveRecord's counter-cache pass fired once per name — the parent's
|
|
41
|
+
count came out doubled on create and doubled on destroy, drifting permanently
|
|
42
|
+
negative once rows predating the alias were removed. The copy no longer
|
|
43
|
+
carries the option; the source reflection still owns the counter. (#93)
|
|
44
|
+
- **Controllers::Paginatable**: `?page=99999999999999999999` was an
|
|
45
|
+
unauthenticated 500 — `(page - 1) * per_page` produced an offset no backend
|
|
46
|
+
accepts (`StatementInvalid` on a relation, `RangeError` on an Array). `page`
|
|
47
|
+
is now clamped to `MAX_PAGE` (1,000,000) and comes back as an empty page past
|
|
48
|
+
the end; `per_page` is held under the matching `MAX_PER_PAGE`, since with
|
|
49
|
+
`max_per_page: 0` ("no cap") the identical value overflowed `LIMIT` instead.
|
|
50
|
+
`paginate_by` also validates `per_page` now: 0 and negatives raise
|
|
51
|
+
`ArgumentError` at class-load time instead of misbehaving on every request
|
|
52
|
+
(`per_page: -1` means `LIMIT -1`, i.e. NO LIMIT on SQLite and MySQL —
|
|
53
|
+
serialising the whole table; `per_page: 0` made every page permanently empty).
|
|
54
|
+
A negative `max_per_page` still means "no cap", as documented. (#94)
|
|
55
|
+
- **Models::Taggable**: `all_tags` raised on PostgreSQL for any model that also
|
|
56
|
+
includes `Models::Sortable` — `SELECT DISTINCT` cannot be ordered by a column
|
|
57
|
+
outside the select list, and Sortable installs exactly such a `default_scope`.
|
|
58
|
+
The inherited `ORDER BY` is dropped with `reorder(nil)`; the result is sorted
|
|
59
|
+
in Ruby anyway. Passed on SQLite, which permits it. (#95)
|
|
60
|
+
- **Models::Lockable, Models::Stateable**: `ActiveRecord::Rollback` raised from
|
|
61
|
+
an `after_lock` / `after_transition` hook did nothing when the call was nested
|
|
62
|
+
inside a caller's own transaction — a bare `transaction` joins the enclosing
|
|
63
|
+
one and Rails swallows `Rollback` without rolling anything back. Both open a
|
|
64
|
+
savepoint now (`requires_new: true`), so the documented abort works: Lockable
|
|
65
|
+
no longer leaves a row locked in the database while reporting `false` in
|
|
66
|
+
memory (with `lock_access!`'s idempotency guard then making every retry a
|
|
67
|
+
no-op), and Stateable no longer commits a state change its hook asked to
|
|
68
|
+
abort. Stateable's `<event>!` also took its return value from `update!`, which
|
|
69
|
+
runs *before* the hook, so an aborted transition reported success —
|
|
70
|
+
`raise unless ticket.archive!` never fired and `transition_all` counted a row
|
|
71
|
+
it had rolled back. It reports `false` now, which `transition_all` treats as
|
|
72
|
+
the documented failed-record signal. Note `transition_all` opens one savepoint
|
|
73
|
+
per record. (#96)
|
|
74
|
+
- **Support::ErrorEnvelope**: the `render_error` lookup was public-only, but
|
|
75
|
+
`render_error` is very often declared under `private` — the idiomatic way to
|
|
76
|
+
keep a controller helper from becoming a routable action. Those overrides were
|
|
77
|
+
silently ignored and the gem's inline envelope rendered instead, so an app
|
|
78
|
+
rendering RFC 9457 problem+json got the wrong shape for every Authorizable
|
|
79
|
+
403, WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400, with
|
|
80
|
+
no error or warning. Now `respond_to?(:render_error, true)`, the spelling
|
|
81
|
+
Authorizable already used for `current_user`. Controllers::Deprecatable keeps
|
|
82
|
+
its own copy of that check before rendering a sunset 410, and it had the same
|
|
83
|
+
blind spot — a private `render_error` with no response object skipped the 410
|
|
84
|
+
and served the sunset action. (#98)
|
|
85
|
+
- **Controllers::Deprecatable**: `deprecate_actions` mutated the caller's own
|
|
86
|
+
`Time`. `Time#utc` is an alias of `#gmtime` and converts the receiver IN
|
|
87
|
+
PLACE, so a host passing a frozen constant (`SUNSET = Time.new(...).freeze`)
|
|
88
|
+
got a `FrozenError` while the controller class body was still loading — the
|
|
89
|
+
app would not boot — and an unfrozen `Time` was silently rewritten to UTC
|
|
90
|
+
behind the caller's back. Now `getutc`. (#99)
|
|
91
|
+
- **Controllers::Filterable**: a boolean `false` read as "filter not supplied",
|
|
92
|
+
so `filter_by :active` could never select the inactive rows — `false.blank?`
|
|
93
|
+
is true, the rule was skipped and the UNFILTERED relation came back. Only JSON
|
|
94
|
+
request bodies were affected; a query string carries the String `"false"`,
|
|
95
|
+
which is not blank. Everything genuinely empty — `nil`, `""`, `" "`, `[]`,
|
|
96
|
+
`{}` — is still skipped, and in `scope:` mode (which discards the value) an
|
|
97
|
+
explicit `false` still means "do not apply this scope". (#100)
|
|
98
|
+
- **Models::Stateable**: `transition_all` silently skipped rows whose state is
|
|
99
|
+
NULL. `where.not(state: to)` compiles to `NOT (state = 'x')`, which SQL
|
|
100
|
+
three-valued logic evaluates to NULL — never TRUE — for a NULL state, so those
|
|
101
|
+
rows were dropped from the batch and from the returned count even though they
|
|
102
|
+
ARE eligible (`may_<event>?` returns true for them and the per-record
|
|
103
|
+
`<event>!` succeeds). The predicate is NULL-safe now. (#101)
|
|
104
|
+
|
|
105
|
+
### Internal
|
|
106
|
+
- **Specs**: the Aliasable join-alias SQL assertion accepts both the Rails 8.1
|
|
107
|
+
`AS`-qualified table alias and the older unqualified form, so the suite passes
|
|
108
|
+
on Rails 8.1 — unblocking the pending Rails 8.1 dependency bumps. No library
|
|
109
|
+
change. (#91)
|
|
110
|
+
|
|
111
|
+
## 1.28.3 (2026-09-16)
|
|
112
|
+
|
|
113
|
+
Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
|
|
114
|
+
the maintainer's request: a SoftDeletable bug fix (`restore_all` /
|
|
115
|
+
`really_destroy_all` dropped the caller's own predicate on the soft-delete
|
|
116
|
+
column), a ColumnGuard change (every missing column reported in one error with
|
|
117
|
+
one migration command) and an additive SoftDeletable `cascade:` option for
|
|
118
|
+
has_many / has_one dependents. No new migrations or runtime dependencies.
|
|
119
|
+
1396 examples, 0 failures.
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
- **Models::SoftDeletable**: `restore_all` and `really_destroy_all` now honour a
|
|
123
|
+
caller's predicate on the soft-delete column. Both used to `unscope` the
|
|
124
|
+
column outright to peel off the default scope's `deleted_at IS NULL`, which
|
|
125
|
+
also dropped `deleted_within(1.hour)` / `where(deleted_at: range)` /
|
|
126
|
+
`only_deleted` — so `User.deleted_within(1.hour).restore_all` restored the
|
|
127
|
+
whole trash can and `only_deleted.really_destroy_all` widened to the whole
|
|
128
|
+
relation. Only the default scope's own predicate is peeled now; predicates on
|
|
129
|
+
other columns (a host model's own `default_scope` included) are untouched. The
|
|
130
|
+
scopes themselves still unscope the column: chain `soft_deleted.where(...)`,
|
|
131
|
+
not `where(...).soft_deleted`. (#41)
|
|
132
|
+
- **Models::Anonymizable**: the stamp column's migration hint now carries its
|
|
133
|
+
type (`anonymized_at:datetime`). (#43)
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
- **Support::ColumnGuard**: a macro that finds several missing columns now
|
|
137
|
+
reports them all in one `ArgumentError` — `'street', 'city' and 'zip' do not
|
|
138
|
+
exist …` — with a single combined migration command
|
|
139
|
+
(`bin/rails generate migration AddAddressableColumnsToUsers street:string
|
|
140
|
+
city:string zip:string`) instead of failing boot once per column. Single-
|
|
141
|
+
column wording and generator name are unchanged. (#43)
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
- **Models::SoftDeletable**: `soft_deletable_by … cascade: %i[comments cover]`
|
|
145
|
+
soft-deletes has_many / has_one dependents with the record (same
|
|
146
|
+
transaction, same timestamp, through their own `soft_delete!` so hooks and
|
|
147
|
+
nested cascades run) and restores exactly those on `restore!` — a dependent
|
|
148
|
+
deleted independently earlier stays deleted. New `soft_delete!(at:)` keyword.
|
|
149
|
+
With a cascade configured, `soft_delete_all` / `restore_all` take the
|
|
150
|
+
per-record path. `belongs_to`, HABTM and `:through` are rejected at class
|
|
151
|
+
load; the target model must include SoftDeletable (checked at class load when
|
|
152
|
+
it already resolves, otherwise on the first cascade). (#52)
|
|
153
|
+
|
|
154
|
+
### Notes
|
|
155
|
+
`cascade:` is off by default — models without it behave exactly as before.
|
|
156
|
+
`restore!` matches cascaded dependents by the parent's exact `deleted_at`, so
|
|
157
|
+
give the columns `precision: 6` (the Rails 7 default) if two parents may be
|
|
158
|
+
deleted within one second. With a cascade configured the single-`UPDATE` fast
|
|
159
|
+
paths of `soft_delete_all` / `restore_all` are disabled (a bulk `UPDATE` cannot
|
|
160
|
+
follow associations). Anything matching `/does not exist/` on a one-column
|
|
161
|
+
ColumnGuard failure still matches — only the several-columns wording and
|
|
162
|
+
generator name changed. The README's "use instead" table no longer lists
|
|
163
|
+
association-cascade soft delete as a reason to reach for paranoia / discard.
|
|
164
|
+
|
|
3
165
|
## 1.28.2 (2026-09-11)
|
|
4
166
|
|
|
5
167
|
Six merged enhancement PRs from the September loop (#42, #84, #59 via #90, #80,
|
data/README.md
CHANGED
|
@@ -146,9 +146,9 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
146
146
|
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
147
147
|
- **One include, one macro** — no boilerplate, no glue code
|
|
148
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
149
|
-
- **Schema-validated configuration** — every macro checks that the configured
|
|
149
|
+
- **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
|
|
150
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
-
- **Tested like an app, not a snippet** — **1,
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,460 RSpec examples** run against a real database on every CI build
|
|
152
152
|
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
153
153
|
|
|
154
154
|
---
|
|
@@ -467,13 +467,21 @@ User.soft_delete_all # soft-deletes all matching records; returns the count
|
|
|
467
467
|
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
|
|
468
468
|
User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
|
|
469
469
|
User.restore_all # restores the matching soft-deleted records; returns the count
|
|
470
|
+
|
|
471
|
+
User.deleted_within(1.hour).restore_all # undo a bulk delete — only the last hour's trash
|
|
472
|
+
User.deleted_within(30.days).really_destroy_all # purge recent trash; older rows untouched
|
|
473
|
+
User.only_deleted.really_destroy_all # empty the trash can, nothing else
|
|
470
474
|
```
|
|
471
475
|
|
|
472
476
|
A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
|
|
473
477
|
batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
|
|
474
|
-
collapse to a single `UPDATE`.
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
collapse to a single `UPDATE`. Both `restore_all` and `really_destroy_all` peel off **only the
|
|
479
|
+
default scope's own** `deleted_at IS NULL`: a predicate *you* put on the column — `deleted_within`,
|
|
480
|
+
`where(deleted_at: range)`, `only_deleted` — survives, as does any other default scope the model
|
|
481
|
+
declares. (Previously they unscoped the column outright, so `deleted_within(1.hour).restore_all`
|
|
482
|
+
restored the whole trash can and `only_deleted.really_destroy_all` widened to the whole relation.)
|
|
483
|
+
The *scopes* still unscope the column, so chain them first: `soft_deleted.where(...)`, not
|
|
484
|
+
`where(...).soft_deleted`.
|
|
477
485
|
|
|
478
486
|
**Scope-name collisions**
|
|
479
487
|
|
|
@@ -490,6 +498,30 @@ Expirable) without a collision. `prefix: true` uses the configured field name. W
|
|
|
490
498
|
passed, scope names, the default scope, and the emitted SQL are unchanged. See the
|
|
491
499
|
Publishable section above for how `prefix:`/`suffix:` differ across the gem.
|
|
492
500
|
|
|
501
|
+
**Cascading to dependents**
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
class Post < ApplicationRecord
|
|
505
|
+
include ConcernsOnRails::SoftDeletable
|
|
506
|
+
has_many :comments
|
|
507
|
+
has_one :cover
|
|
508
|
+
soft_deletable_by :deleted_at, cascade: %i[comments cover] # Comment and Cover include SoftDeletable too
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
post.soft_delete! # comments + cover soft-deleted in the same transaction, with the post's exact timestamp
|
|
512
|
+
post.restore! # brings back the comments/cover the cascade deleted — NOT a comment someone trashed last week
|
|
513
|
+
post.soft_delete!(at: 1.day.ago) # new at: keyword — backdate, or hand a timestamp down a cascade
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Dependents go through their own `soft_delete!` / `restore!` (hooks and nested cascades run). A dependent
|
|
517
|
+
that fails — whether it raises or just fails validation — aborts the cascade with
|
|
518
|
+
`ActiveRecord::RecordNotSaved` and rolls the parent back with it, so you never end up with a deleted
|
|
519
|
+
parent and a live child. Declare the cascaded associations **above** `soft_deletable_by`; the macro
|
|
520
|
+
resolves them at class load. Restore matches on the parent's timestamp, so independently
|
|
521
|
+
deleted dependents keep their own. `cascade:` accepts `has_many` / `has_one` (no `belongs_to`, HABTM or
|
|
522
|
+
`:through`) whose models include SoftDeletable; with a cascade configured `soft_delete_all` / `restore_all`
|
|
523
|
+
take the per-record path (a bulk `UPDATE` cannot follow associations).
|
|
524
|
+
|
|
493
525
|
**Lifecycle hooks** — override these methods on the model:
|
|
494
526
|
|
|
495
527
|
```ruby
|
|
@@ -1385,6 +1417,7 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
|
1385
1417
|
**Notes**
|
|
1386
1418
|
- The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
|
|
1387
1419
|
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1420
|
+
- `ssn_ciphertext` is `nil` while the field has an unsaved change (so it can never return the plaintext you just assigned), and `ssn_encrypted?` asks whether what is stored really is an envelope.
|
|
1388
1421
|
- Never `update_column(s)` an encrypted field — that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
|
|
1389
1422
|
- 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.
|
|
1390
1423
|
- Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned — the envelope already reserves the `key_id` byte).
|
|
@@ -2183,7 +2216,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
2183
2216
|
| Need | Use instead |
|
|
2184
2217
|
|------|-------------|
|
|
2185
2218
|
| Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
|
|
2186
|
-
|
|
|
2219
|
+
| Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
2187
2220
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
2188
2221
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
2189
2222
|
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
|
|
@@ -2217,9 +2250,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2217
2250
|
|
|
2218
2251
|
```sh
|
|
2219
2252
|
bundle install # install dev dependencies
|
|
2220
|
-
bundle exec rspec # run the test suite (1,
|
|
2253
|
+
bundle exec rspec # run the test suite (1,460 examples)
|
|
2221
2254
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2222
|
-
gem install ./concerns_on_rails-1.28.
|
|
2255
|
+
gem install ./concerns_on_rails-1.28.4.gem # install locally
|
|
2223
2256
|
|
|
2224
2257
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2225
2258
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -141,8 +141,15 @@ module ConcernsOnRails
|
|
|
141
141
|
# Module#=== checks the real ancestry, so `when Time` alone would
|
|
142
142
|
# miss it — and Time.current / 1.month.from_now are exactly the
|
|
143
143
|
# values Rails hosts pass.
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
# getutc, NOT utc: Time#utc is an alias of #gmtime, which converts the
|
|
145
|
+
# receiver IN PLACE and returns self. A host passing a frozen
|
|
146
|
+
# constant (SUNSET = Time.new(...).freeze) got a FrozenError while
|
|
147
|
+
# the controller class body was still loading — the app would not
|
|
148
|
+
# boot — and an unfrozen Time was silently rewritten to UTC behind
|
|
149
|
+
# the caller's back. TimeWithZone#utc is a harmless reader, but
|
|
150
|
+
# getutc is correct for both, so the branch stays single.
|
|
151
|
+
when ActiveSupport::TimeWithZone, Time then value.getutc
|
|
152
|
+
when DateTime then value.to_time.getutc
|
|
146
153
|
when Date then Time.utc(value.year, value.month, value.day)
|
|
147
154
|
when String then parse_deprecation_string(value)
|
|
148
155
|
end
|
|
@@ -247,7 +254,12 @@ module ConcernsOnRails
|
|
|
247
254
|
return unless deprecation_sunset_reached?(rule)
|
|
248
255
|
|
|
249
256
|
message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
|
|
250
|
-
|
|
257
|
+
# respond_to?(..., true) for the same reason Support::ErrorEnvelope
|
|
258
|
+
# uses it: render_error is very often declared under `private`. The
|
|
259
|
+
# public-only check skipped the 410 for exactly those controllers and
|
|
260
|
+
# let the sunset action run — a fail-open on the branch whose job is to
|
|
261
|
+
# stop serving the endpoint.
|
|
262
|
+
return unless respond_to?(:render_error, true) || (respond_to?(:response) && response)
|
|
251
263
|
|
|
252
264
|
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
|
|
253
265
|
end
|
|
@@ -42,12 +42,12 @@ module ConcernsOnRails
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Apply all declared filters to a relation based on params.
|
|
46
|
-
# are skipped so
|
|
45
|
+
# Apply all declared filters to a relation based on params. Unset values
|
|
46
|
+
# are skipped so absent filters don't narrow the relation.
|
|
47
47
|
def filtered(relation)
|
|
48
48
|
self.class.filterable_rules.each do |field, options|
|
|
49
49
|
value = params[field]
|
|
50
|
-
next if value
|
|
50
|
+
next if filterable_unset?(value)
|
|
51
51
|
|
|
52
52
|
relation = apply_filter(relation, field, value, options)
|
|
53
53
|
end
|
|
@@ -56,11 +56,29 @@ module ConcernsOnRails
|
|
|
56
56
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
|
+
# NOT `value.blank?`: `false.blank?` is true, so a genuine boolean false
|
|
60
|
+
# read as "filter not supplied" and the relation came back UNFILTERED —
|
|
61
|
+
# `filter_by :active` could never select the inactive rows. Query strings
|
|
62
|
+
# were unaffected (they carry the String "false", which is not blank), so
|
|
63
|
+
# this only bit JSON request bodies, where the value really is `false`.
|
|
64
|
+
# Everything actually empty — nil, "", " ", [], {} — is still skipped.
|
|
65
|
+
def filterable_unset?(value)
|
|
66
|
+
return false if value == false
|
|
67
|
+
return true if value.nil?
|
|
68
|
+
|
|
69
|
+
value.respond_to?(:blank?) ? value.blank? : false
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
def apply_filter(relation, field, value, options)
|
|
60
73
|
if options[:with]
|
|
61
74
|
options[:with].call(relation, value)
|
|
62
75
|
elsif options[:scope]
|
|
63
|
-
|
|
76
|
+
# Scope mode discards the value, so an explicit `false` can only mean
|
|
77
|
+
# "do not apply this scope" — applying it would hand the client the
|
|
78
|
+
# exact opposite of what it asked for. (A query string still carries
|
|
79
|
+
# the String "false", which has always triggered the scope; only a
|
|
80
|
+
# real boolean is read as a negation.)
|
|
81
|
+
value == false ? relation : relation.public_send(options[:scope])
|
|
64
82
|
elsif filterable_scalar?(value)
|
|
65
83
|
relation.where(field => value)
|
|
66
84
|
else
|
|
@@ -36,6 +36,21 @@ module ConcernsOnRails
|
|
|
36
36
|
LABEL = "ConcernsOnRails::Controllers::Paginatable".freeze
|
|
37
37
|
DEFAULT_PER_PAGE = 25
|
|
38
38
|
DEFAULT_MAX_PER_PAGE = 200
|
|
39
|
+
# Upper bound on the requested page. `page` is untrusted input and its
|
|
40
|
+
# only job is to become `(page - 1) * per_page`, so an unbounded value
|
|
41
|
+
# produced an offset no backend can take: the relation branch raised
|
|
42
|
+
# StatementInvalid and Array#[] raised RangeError ("bignum too big to
|
|
43
|
+
# convert into `long'") — a 500 from `?page=99999999999999999999`.
|
|
44
|
+
# Clamping keeps the request in range; the page is far past any real
|
|
45
|
+
# dataset, so it simply comes back empty. Deep pagination at this depth
|
|
46
|
+
# wants Controllers::CursorPaginatable instead.
|
|
47
|
+
MAX_PAGE = 1_000_000
|
|
48
|
+
# The same guard for per_page. `max_per_page: 0` is documented as "no
|
|
49
|
+
# cap", and with no cap the identical untrusted value overflowed LIMIT
|
|
50
|
+
# instead of OFFSET — the same unauthenticated 500, one option away. "No
|
|
51
|
+
# cap" means no CONFIGURED cap, not an unbounded LIMIT; a page of a
|
|
52
|
+
# million records is already far past what any client can render.
|
|
53
|
+
MAX_PER_PAGE = 1_000_000
|
|
39
54
|
|
|
40
55
|
included do
|
|
41
56
|
class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
|
|
@@ -59,8 +74,8 @@ module ConcernsOnRails
|
|
|
59
74
|
# paginate_by per_page: 50, max_per_page: 500, link_header: false
|
|
60
75
|
def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
|
|
61
76
|
page_param: nil, per_page_param: nil, style: :flat, window: nil)
|
|
62
|
-
self.paginatable_per_page = per_page
|
|
63
|
-
self.paginatable_max_per_page = max_per_page
|
|
77
|
+
self.paginatable_per_page = paginatable_per_page!(per_page)
|
|
78
|
+
self.paginatable_max_per_page = paginatable_max_per_page!(max_per_page)
|
|
64
79
|
self.paginatable_link_header = link_header ? true : false
|
|
65
80
|
self.paginatable_window = paginatable_window!(window)
|
|
66
81
|
defaults = paginatable_style_params!(style)
|
|
@@ -79,6 +94,29 @@ module ConcernsOnRails
|
|
|
79
94
|
end
|
|
80
95
|
end
|
|
81
96
|
|
|
97
|
+
# per_page must be positive. A bare `.to_i` let a negative through, and
|
|
98
|
+
# `LIMIT -1` means NO LIMIT on SQLite and MySQL — so `per_page: -1`
|
|
99
|
+
# silently serialized the entire table on every request, while
|
|
100
|
+
# `per_page: 0` made every page permanently empty. Both are broken
|
|
101
|
+
# configuration with no sane reading, so they raise at class-load time
|
|
102
|
+
# rather than misbehaving on every request.
|
|
103
|
+
def paginatable_per_page!(value)
|
|
104
|
+
size = value.to_i
|
|
105
|
+
return size if size.positive?
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "#{LABEL}: per_page: must be a positive integer (got #{value.inspect})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# max_per_page does NOT raise: "0 or a negative integer disables the
|
|
111
|
+
# cap" is this option's documented contract, so rejecting a negative
|
|
112
|
+
# would fail the boot of an app that is configured exactly as written —
|
|
113
|
+
# and on a patch upgrade at that. Normalize to 0 instead; the reader's
|
|
114
|
+
# guard only asks whether the cap is positive.
|
|
115
|
+
def paginatable_max_per_page!(value)
|
|
116
|
+
size = value.to_i
|
|
117
|
+
size.negative? ? 0 : size
|
|
118
|
+
end
|
|
119
|
+
|
|
82
120
|
# nil / false disable the window (no `pages:` key). `0` is meaningful:
|
|
83
121
|
# first, current and last only.
|
|
84
122
|
def paginatable_window!(value)
|
|
@@ -206,14 +244,18 @@ module ConcernsOnRails
|
|
|
206
244
|
# Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
|
|
207
245
|
# arrive as Array/Parameters, and calling .to_i on those was a 500.
|
|
208
246
|
def pagination_page
|
|
209
|
-
|
|
247
|
+
requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0)
|
|
248
|
+
requested.clamp(1, MAX_PAGE)
|
|
210
249
|
end
|
|
211
250
|
|
|
212
251
|
def pagination_per_page
|
|
213
252
|
requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
|
|
214
253
|
requested = self.class.paginatable_per_page if requested < 1
|
|
215
254
|
cap = self.class.paginatable_max_per_page
|
|
216
|
-
|
|
255
|
+
requested = [requested, cap].min if cap.positive?
|
|
256
|
+
# Applied even when a cap IS configured: `max_per_page: 10**30` is its
|
|
257
|
+
# own way of asking for the overflow back.
|
|
258
|
+
[requested, MAX_PER_PAGE].min
|
|
217
259
|
end
|
|
218
260
|
|
|
219
261
|
# Dig the configured path out of params: `["page"]` → params[:page];
|
|
@@ -178,19 +178,68 @@ module ConcernsOnRails
|
|
|
178
178
|
# Single funnel for all failure outcomes (override point). Uses
|
|
179
179
|
# Respondable's render_error when available, otherwise the same inline
|
|
180
180
|
# envelope as Throttleable / Idempotentable.
|
|
181
|
+
#
|
|
182
|
+
# Fails CLOSED, matching Authorizable#authorization_denied: when there is
|
|
183
|
+
# nothing to render the rejection into, raise. Returning nil here (the
|
|
184
|
+
# pre-1.29 behavior) left the before_action chain unhalted, so the action
|
|
185
|
+
# ran on an unverified — possibly forged — payload.
|
|
181
186
|
def webhook_verification_failed(message:, status:, code:)
|
|
182
|
-
|
|
187
|
+
unless webhook_can_render?
|
|
188
|
+
raise "ConcernsOnRails::Controllers::WebhookVerifiable: rejection for " \
|
|
189
|
+
"'#{webhook_action_name || '(unknown action)'}' could not be rendered " \
|
|
190
|
+
"(no response object) — refusing to fail open"
|
|
191
|
+
end
|
|
183
192
|
|
|
184
193
|
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: code)
|
|
185
194
|
end
|
|
186
195
|
|
|
187
196
|
private
|
|
188
197
|
|
|
198
|
+
# Mirrors the render path in Support::ErrorEnvelope: a render_error
|
|
199
|
+
# override is enough on its own, so a controller that supplies one but no
|
|
200
|
+
# response object still rejects properly instead of raising.
|
|
201
|
+
def webhook_can_render?
|
|
202
|
+
respond_to?(:render_error, true) || (respond_to?(:response) && response)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# nil when the action cannot be determined. `action_name` can also be ""
|
|
206
|
+
# (truthy), which a bare `unless action` guard would let through.
|
|
207
|
+
def webhook_action_name
|
|
208
|
+
return nil unless respond_to?(:action_name)
|
|
209
|
+
|
|
210
|
+
name = action_name.to_s
|
|
211
|
+
name.empty? ? nil : name
|
|
212
|
+
end
|
|
213
|
+
|
|
189
214
|
def webhook_rule_for_action
|
|
190
|
-
|
|
191
|
-
return nil
|
|
215
|
+
rules = self.class.webhook_rules
|
|
216
|
+
return nil if rules.empty?
|
|
217
|
+
|
|
218
|
+
action = webhook_action_name
|
|
219
|
+
# Fail closed: rules ARE declared but we cannot tell which action this
|
|
220
|
+
# is, so "no rule applies" is not a conclusion we may draw. Previously
|
|
221
|
+
# an unresolvable action_name skipped verification entirely and every
|
|
222
|
+
# webhook was accepted without a signature check.
|
|
223
|
+
return webhook_unresolvable_action_rule(rules) if action.nil?
|
|
224
|
+
|
|
225
|
+
rules.find { |rule| rule[:actions].empty? || rule[:actions].include?(action) }
|
|
226
|
+
end
|
|
192
227
|
|
|
193
|
-
|
|
228
|
+
# Which rule to verify against when the action cannot be resolved. A
|
|
229
|
+
# catch-all is well defined, and so is a lone rule. Several
|
|
230
|
+
# action-specific rules are NOT: each carries its own provider secret and
|
|
231
|
+
# scheme, so picking the first would reject a perfectly valid delivery
|
|
232
|
+
# with "signature invalid" — sending that provider chasing a signing bug
|
|
233
|
+
# that does not exist. Raise instead. Still fails closed either way; this
|
|
234
|
+
# one just says what actually went wrong.
|
|
235
|
+
def webhook_unresolvable_action_rule(rules)
|
|
236
|
+
catch_all = rules.find { |rule| rule[:actions].empty? }
|
|
237
|
+
return catch_all if catch_all
|
|
238
|
+
return rules.first if rules.one?
|
|
239
|
+
|
|
240
|
+
raise "ConcernsOnRails::Controllers::WebhookVerifiable: cannot tell which action this request is " \
|
|
241
|
+
"(no action_name) and #{rules.size} action-specific rules are declared with no catch-all — " \
|
|
242
|
+
"refusing to verify against an arbitrary rule's secret"
|
|
194
243
|
end
|
|
195
244
|
|
|
196
245
|
def webhook_render_outcome(rule, outcome)
|
|
@@ -274,6 +274,15 @@ module ConcernsOnRails
|
|
|
274
274
|
# polymorphic).
|
|
275
275
|
def aliasable_copy_options(src)
|
|
276
276
|
opts = src.options.dup
|
|
277
|
+
# The SOURCE reflection already owns the counter. Carrying the option
|
|
278
|
+
# onto the copy makes ActiveRecord::CounterCache count it twice: it
|
|
279
|
+
# iterates _reflections and calls association(name) for each
|
|
280
|
+
# counter-cached one, and the #association override below maps the
|
|
281
|
+
# alias back to the SAME association object, so increment_counters
|
|
282
|
+
# fires once per name with no dedup guard. The parent's count came
|
|
283
|
+
# out doubled on create and doubled on destroy — drifting
|
|
284
|
+
# permanently negative once rows predating the alias were removed.
|
|
285
|
+
opts.delete(:counter_cache)
|
|
277
286
|
if opts[:through]
|
|
278
287
|
opts[:source] ||= aliasable_through_source_name(src)
|
|
279
288
|
else
|
|
@@ -96,7 +96,7 @@ module ConcernsOnRails
|
|
|
96
96
|
anonymizable_apply_options(stamp, clear_audit_trail)
|
|
97
97
|
|
|
98
98
|
ensure_columns!(LABEL, fields)
|
|
99
|
-
ensure_columns!(LABEL, anonymizable_stamp) if anonymizable_stamp
|
|
99
|
+
ensure_columns!(LABEL, anonymizable_stamp, types: :datetime) if anonymizable_stamp
|
|
100
100
|
self.anonymizable_rules = anonymizable_rules.merge(fields.to_h { |f| [f.to_sym, strategy] })
|
|
101
101
|
|
|
102
102
|
anonymizable_define_scopes(prefix, suffix)
|
|
@@ -271,11 +271,34 @@ module ConcernsOnRails
|
|
|
271
271
|
end
|
|
272
272
|
|
|
273
273
|
def encryptable_define_helpers(field)
|
|
274
|
-
#
|
|
275
|
-
# deserializes it
|
|
276
|
-
# plaintext is at rest.
|
|
277
|
-
|
|
278
|
-
|
|
274
|
+
# The value AT REST — the column's stored content, before the type
|
|
275
|
+
# deserializes it. Useful for migrations, debugging, and asserting no
|
|
276
|
+
# plaintext is at rest. nil while the field carries an unsaved change.
|
|
277
|
+
#
|
|
278
|
+
# That last clause is the fix: this used to return
|
|
279
|
+
# read_attribute_before_type_cast unconditionally, and for a column
|
|
280
|
+
# overridden with `attribute` that is the caller's PLAINTEXT whenever
|
|
281
|
+
# the value has not round-tripped through the database — a new record,
|
|
282
|
+
# or any record with a pending assignment (i.e. exactly the state
|
|
283
|
+
# inside a before_save, a validator, or an error-reporting path). A
|
|
284
|
+
# reader named `_ciphertext`, documented for "asserting no plaintext
|
|
285
|
+
# is at rest", handed back the SSN, so `log.info(user.ssn_ciphertext)`
|
|
286
|
+
# wrote it straight to the log.
|
|
287
|
+
define_method("#{field}_ciphertext") do
|
|
288
|
+
next nil if new_record? || public_send("#{field}_changed?")
|
|
289
|
+
|
|
290
|
+
read_attribute_before_type_cast(field)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# True only when what is stored really is an encryption envelope. The
|
|
294
|
+
# old `.present?` was true for plaintext too, so the natural guard
|
|
295
|
+
# `raise unless user.ssn_encrypted?` passed on a record whose column
|
|
296
|
+
# held the raw value. Note this is honestly false under
|
|
297
|
+
# `on_missing_key: :passthrough`, where plaintext at rest is the
|
|
298
|
+
# opted-into behavior.
|
|
299
|
+
define_method("#{field}_encrypted?") do
|
|
300
|
+
ConcernsOnRails::Support::Encryptor.envelope?(public_send("#{field}_ciphertext"))
|
|
301
|
+
end
|
|
279
302
|
end
|
|
280
303
|
|
|
281
304
|
# find_by_<field> / where_<field> / <field>_fingerprint for equality
|
|
@@ -313,7 +313,15 @@ module ConcernsOnRails
|
|
|
313
313
|
def lockable_write_with_hooks(previous_values)
|
|
314
314
|
completed = false
|
|
315
315
|
begin
|
|
316
|
-
transaction
|
|
316
|
+
# requires_new: a bare `transaction` JOINS an enclosing one rather
|
|
317
|
+
# than opening a savepoint, and Rails then swallows
|
|
318
|
+
# ActiveRecord::Rollback without rolling anything back. Inside a
|
|
319
|
+
# caller's `ApplicationRecord.transaction { user.lock_access! }`,
|
|
320
|
+
# a hook raising Rollback left the row locked in the database while
|
|
321
|
+
# the ensure below restored locked_at = nil in memory and this
|
|
322
|
+
# method returned false — and the idempotency guard in
|
|
323
|
+
# lock_access! then made every retry a no-op.
|
|
324
|
+
transaction(requires_new: true) do
|
|
317
325
|
yield
|
|
318
326
|
completed = true
|
|
319
327
|
end
|
|
@@ -22,6 +22,9 @@ module ConcernsOnRails
|
|
|
22
22
|
class_attribute :soft_delete_scope_names, instance_accessor: false,
|
|
23
23
|
default: SCOPE_BASES.to_h { |b| [b, b] }.freeze
|
|
24
24
|
class_attribute :soft_delete_captured_scopes, instance_accessor: false, default: {}.freeze
|
|
25
|
+
# has_many / has_one association names soft-deleted and restored along
|
|
26
|
+
# with this record (their models must include SoftDeletable too).
|
|
27
|
+
class_attribute :soft_delete_cascade, instance_accessor: false, default: [].freeze
|
|
25
28
|
|
|
26
29
|
define_soft_delete_scopes(nil, nil)
|
|
27
30
|
self.soft_delete_captured_scopes =
|
|
@@ -45,11 +48,23 @@ module ConcernsOnRails
|
|
|
45
48
|
# Example:
|
|
46
49
|
# soft_deletable_by :deleted_at, touch: false
|
|
47
50
|
# soft_deletable_by :deleted_at, default_scope: false # don't hide deleted rows from .all
|
|
48
|
-
|
|
51
|
+
# soft_deletable_by :deleted_at, cascade: %i[comments attachments]
|
|
52
|
+
#
|
|
53
|
+
# `cascade:` names has_many / has_one associations whose records are
|
|
54
|
+
# soft-deleted with the parent (inside its transaction, with the
|
|
55
|
+
# parent's exact timestamp, through their own soft_delete! — hooks and
|
|
56
|
+
# nested cascades included) and restored with it. Restore only touches
|
|
57
|
+
# dependents carrying the parent's timestamp, so a comment someone
|
|
58
|
+
# deleted independently last week stays deleted when the post comes
|
|
59
|
+
# back. Every target model must include SoftDeletable. With a cascade
|
|
60
|
+
# configured the single-UPDATE batch fast paths are disabled, since a
|
|
61
|
+
# bulk UPDATE could not follow the associations.
|
|
62
|
+
def soft_deletable_by(field = nil, touch: true, default_scope: true, prefix: nil, suffix: nil, cascade: nil)
|
|
49
63
|
self.soft_delete_field = field || :deleted_at
|
|
50
64
|
self.soft_delete_touch = touch
|
|
51
65
|
self.soft_delete_default_scope = default_scope
|
|
52
66
|
ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field, types: :datetime)
|
|
67
|
+
self.soft_delete_cascade = soft_delete_validate_cascade!(cascade)
|
|
53
68
|
return unless prefix || suffix
|
|
54
69
|
|
|
55
70
|
define_soft_delete_scopes(prefix, suffix)
|
|
@@ -82,22 +97,27 @@ module ConcernsOnRails
|
|
|
82
97
|
soft_delete_all
|
|
83
98
|
end
|
|
84
99
|
|
|
85
|
-
# Hard-delete every record matching the CURRENT relation —
|
|
86
|
-
#
|
|
87
|
-
# peeled off
|
|
88
|
-
#
|
|
89
|
-
#
|
|
100
|
+
# Hard-delete every record matching the CURRENT relation — soft-deleted
|
|
101
|
+
# rows included. Only the default scope's own `deleted_at IS NULL` is
|
|
102
|
+
# peeled off; a caller's predicate on the column survives, so
|
|
103
|
+
# `only_deleted.really_destroy_all` purges the trash and nothing else
|
|
104
|
+
# and `deleted_within(30.days).really_destroy_all` purges recent trash.
|
|
90
105
|
# (Before 1.22 this ignored the relation entirely and hard-deleted the
|
|
91
|
-
# complete table
|
|
106
|
+
# complete table; until this fix it unscoped the column outright, which
|
|
107
|
+
# widened `only_deleted.really_destroy_all` to the whole relation.)
|
|
92
108
|
def really_destroy_all
|
|
93
|
-
|
|
109
|
+
soft_delete_without_default_scope.delete_all
|
|
94
110
|
end
|
|
95
111
|
|
|
96
|
-
# Restore every soft-deleted record (mirror of
|
|
97
|
-
# Integer count, RecordNotSaved + rollback on
|
|
98
|
-
# when the fast path applies.
|
|
112
|
+
# Restore every soft-deleted record in the relation (mirror of
|
|
113
|
+
# soft_delete_all): Integer count, RecordNotSaved + rollback on
|
|
114
|
+
# failure, single UPDATE when the fast path applies. Built against the
|
|
115
|
+
# current relation rather than routed through the `soft_deleted` scope,
|
|
116
|
+
# whose `unscope(where: deleted_at)` also stripped the CALLER's predicate
|
|
117
|
+
# on the column — `deleted_within(1.hour).restore_all` restored the
|
|
118
|
+
# whole trash can. (Same defect `publish_all` fixed in 1.27.)
|
|
99
119
|
def restore_all
|
|
100
|
-
deleted =
|
|
120
|
+
deleted = soft_delete_without_default_scope.where.not(soft_delete_field => nil)
|
|
101
121
|
return deleted.update_all(soft_delete_field => nil) if soft_delete_batch_fast_path?(:restore)
|
|
102
122
|
|
|
103
123
|
ConcernsOnRails::Support::BatchOps.run(
|
|
@@ -110,6 +130,27 @@ module ConcernsOnRails
|
|
|
110
130
|
|
|
111
131
|
private
|
|
112
132
|
|
|
133
|
+
# The current relation with the DEFAULT SCOPE's soft-delete predicate
|
|
134
|
+
# peeled off — and nothing else. `unscope(where: field)` (what the
|
|
135
|
+
# scopes do) strips every predicate on the column, the caller's
|
|
136
|
+
# included; so unscope, then put back the predicates the caller added on
|
|
137
|
+
# that column. "Added by the caller" is the relation's where clause
|
|
138
|
+
# minus the default scope's own, using the same structural WhereClause
|
|
139
|
+
# arithmetic Rails' `merge`/`except` rely on. Predicates on OTHER
|
|
140
|
+
# columns — a host model's own `default_scope { where(tenant_id:) }`
|
|
141
|
+
# included — are never touched. With `default_scope: false` there is
|
|
142
|
+
# nothing to peel.
|
|
143
|
+
def soft_delete_without_default_scope
|
|
144
|
+
relation = all
|
|
145
|
+
return relation unless soft_delete_default_scope
|
|
146
|
+
|
|
147
|
+
callers = relation.where_clause - default_scoped.where_clause
|
|
148
|
+
callers_on_column = callers - callers.except(soft_delete_field.to_s)
|
|
149
|
+
peeled = relation.unscope(where: soft_delete_field)
|
|
150
|
+
peeled.where_clause += callers_on_column unless callers_on_column.empty?
|
|
151
|
+
peeled
|
|
152
|
+
end
|
|
153
|
+
|
|
113
154
|
# Built here rather than inline in `included do` so the names can be
|
|
114
155
|
# affixed. Every scope that references another scope resolves it
|
|
115
156
|
# through soft_delete_scope_names — a hard-coded symbol would break
|
|
@@ -152,7 +193,7 @@ module ConcernsOnRails
|
|
|
152
193
|
# reason: under `touch: false` both paths skip validations already, so
|
|
153
194
|
# only the ownership half (`unoverridden?`) applies.
|
|
154
195
|
def soft_delete_batch_fast_path?(kind)
|
|
155
|
-
return false if soft_delete_touch
|
|
196
|
+
return false if soft_delete_touch || soft_delete_cascade.any?
|
|
156
197
|
|
|
157
198
|
methods = if kind == :restore
|
|
158
199
|
%i[before_restore after_restore restore!]
|
|
@@ -161,6 +202,47 @@ module ConcernsOnRails
|
|
|
161
202
|
end
|
|
162
203
|
ConcernsOnRails::Support::BatchOps.unoverridden?(self, ConcernsOnRails::Models::SoftDeletable, *methods)
|
|
163
204
|
end
|
|
205
|
+
|
|
206
|
+
# Each cascade target must be a has_many / has_one (not through) whose
|
|
207
|
+
# model includes SoftDeletable. The association shape is checked at
|
|
208
|
+
# class load; the target model is checked here when it already
|
|
209
|
+
# resolves, and otherwise on first cascade (a not-yet-loaded or
|
|
210
|
+
# anonymous class cannot be resolved from inside a class body).
|
|
211
|
+
def soft_delete_validate_cascade!(cascade)
|
|
212
|
+
names = Array(cascade).map(&:to_sym)
|
|
213
|
+
names.each do |name|
|
|
214
|
+
reflection = reflect_on_association(name)
|
|
215
|
+
raise ArgumentError, "#{soft_delete_label}: cascade: '#{name}' is not an association of #{self.name}" unless reflection
|
|
216
|
+
unless %i[has_many has_one].include?(reflection.macro)
|
|
217
|
+
raise ArgumentError,
|
|
218
|
+
"#{soft_delete_label}: cascade: '#{name}' must be a has_many or has_one (got #{reflection.macro})"
|
|
219
|
+
end
|
|
220
|
+
if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
|
221
|
+
raise ArgumentError, "#{soft_delete_label}: cascade: '#{name}' is a :through association; cascade to the source instead"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
soft_delete_check_cascade_target!(name, reflection) if soft_delete_cascade_resolvable?(reflection)
|
|
225
|
+
end
|
|
226
|
+
names.freeze
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def soft_delete_cascade_resolvable?(reflection)
|
|
230
|
+
reflection.klass
|
|
231
|
+
true
|
|
232
|
+
rescue NameError # NoMethodError (anonymous class: nil name) is a NameError
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def soft_delete_check_cascade_target!(name, reflection)
|
|
237
|
+
return if reflection.klass.respond_to?(:soft_delete_field)
|
|
238
|
+
|
|
239
|
+
raise ArgumentError,
|
|
240
|
+
"#{soft_delete_label}: cascade: '#{name}' targets #{reflection.klass.name}, which does not include SoftDeletable"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def soft_delete_label
|
|
244
|
+
"ConcernsOnRails::Models::SoftDeletable"
|
|
245
|
+
end
|
|
164
246
|
end
|
|
165
247
|
|
|
166
248
|
# Soft delete hooks
|
|
@@ -169,7 +251,9 @@ module ConcernsOnRails
|
|
|
169
251
|
def before_restore; end
|
|
170
252
|
def after_restore; end
|
|
171
253
|
|
|
172
|
-
|
|
254
|
+
# `at:` sets the timestamp (default now) — it is what the cascade uses to
|
|
255
|
+
# hand the parent's exact timestamp down, and lets callers backdate.
|
|
256
|
+
def soft_delete!(at: Time.zone.now)
|
|
173
257
|
return true if deleted?
|
|
174
258
|
|
|
175
259
|
result = false
|
|
@@ -178,10 +262,11 @@ module ConcernsOnRails
|
|
|
178
262
|
transaction do
|
|
179
263
|
before_soft_delete
|
|
180
264
|
result = if self.class.soft_delete_touch
|
|
181
|
-
update(self.class.soft_delete_field =>
|
|
265
|
+
update(self.class.soft_delete_field => at)
|
|
182
266
|
else
|
|
183
|
-
update_column(self.class.soft_delete_field,
|
|
267
|
+
update_column(self.class.soft_delete_field, at)
|
|
184
268
|
end
|
|
269
|
+
soft_delete_cascade_dependents!(at) if result
|
|
185
270
|
after_soft_delete if result
|
|
186
271
|
end
|
|
187
272
|
result
|
|
@@ -190,6 +275,7 @@ module ConcernsOnRails
|
|
|
190
275
|
def restore!
|
|
191
276
|
return true unless deleted?
|
|
192
277
|
|
|
278
|
+
stamp = self[self.class.soft_delete_field]
|
|
193
279
|
result = false
|
|
194
280
|
transaction do
|
|
195
281
|
before_restore
|
|
@@ -198,6 +284,7 @@ module ConcernsOnRails
|
|
|
198
284
|
else
|
|
199
285
|
update_column(self.class.soft_delete_field, nil)
|
|
200
286
|
end
|
|
287
|
+
restore_cascaded_dependents!(stamp) if result
|
|
201
288
|
after_restore if result
|
|
202
289
|
end
|
|
203
290
|
result
|
|
@@ -221,6 +308,54 @@ module ConcernsOnRails
|
|
|
221
308
|
def is_really_deleted?
|
|
222
309
|
!self.class.unscoped.exists?(id)
|
|
223
310
|
end
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
# Soft-delete every not-yet-deleted dependent with the parent's timestamp.
|
|
315
|
+
# Goes through each record's own soft_delete! so its hooks and its own
|
|
316
|
+
# cascade run; a dependent deleted earlier keeps its own timestamp.
|
|
317
|
+
def soft_delete_cascade_dependents!(at)
|
|
318
|
+
soft_delete_each_dependent(deleted: false) do |dependent|
|
|
319
|
+
soft_delete_cascade_check!(dependent, dependent.soft_delete!(at: at), "soft-delete")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Restore only the dependents that carry the parent's timestamp — the
|
|
324
|
+
# ones this cascade deleted — and let them restore their own dependents.
|
|
325
|
+
def restore_cascaded_dependents!(stamp)
|
|
326
|
+
soft_delete_each_dependent(deleted: stamp) do |dependent|
|
|
327
|
+
soft_delete_cascade_check!(dependent, dependent.restore!, "restore")
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A dependent that fails to save must not be skipped silently: with the
|
|
332
|
+
# default `touch: true` the write goes through `update`, which returns
|
|
333
|
+
# false on a validation failure instead of raising. Mirror the batch
|
|
334
|
+
# contract (Support::BatchOps) and raise RecordNotSaved, which rolls the
|
|
335
|
+
# whole cascade — and the parent's own change — back.
|
|
336
|
+
def soft_delete_cascade_check!(dependent, result, verb)
|
|
337
|
+
return if result
|
|
338
|
+
|
|
339
|
+
raise ActiveRecord::RecordNotSaved.new(
|
|
340
|
+
"#{self.class.send(:soft_delete_label)}: failed to cascade #{verb} to " \
|
|
341
|
+
"#{dependent.class.name}(id: #{dependent.id.inspect})", dependent
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Yields the records of every cascade association matching `deleted:`
|
|
346
|
+
# (false → not deleted, a timestamp → deleted at exactly that time).
|
|
347
|
+
# The association's default scope is peeled off so deleted rows are
|
|
348
|
+
# reachable; has_one is handled through the same relation.
|
|
349
|
+
def soft_delete_each_dependent(deleted:, &block)
|
|
350
|
+
self.class.soft_delete_cascade.each do |name|
|
|
351
|
+
reflection = self.class.reflect_on_association(name)
|
|
352
|
+
self.class.send(:soft_delete_check_cascade_target!, name, reflection)
|
|
353
|
+
field = reflection.klass.soft_delete_field
|
|
354
|
+
relation = association(name).scope.unscope(where: field)
|
|
355
|
+
relation = deleted ? relation.where(field => deleted) : relation.where(field => nil)
|
|
356
|
+
relation.find_each(&block)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
224
359
|
end
|
|
225
360
|
end
|
|
226
361
|
end
|
|
@@ -113,7 +113,15 @@ module ConcernsOnRails
|
|
|
113
113
|
method_base = stateable_method_name(name)
|
|
114
114
|
|
|
115
115
|
eligible = from.empty? ? all : all.where(field => from)
|
|
116
|
-
|
|
116
|
+
# NULL-safe: `where.not(field => to)` compiles to `NOT (state = 'x')`,
|
|
117
|
+
# which SQL three-valued logic evaluates to NULL — never TRUE — for a
|
|
118
|
+
# NULL state, so those rows were silently dropped from the batch and
|
|
119
|
+
# from the returned count. They ARE eligible: a transition with no
|
|
120
|
+
# `from:` is documented as allowed from any state, `may_<event>?`
|
|
121
|
+
# returns true for them, and `record.<event>!` on the same row
|
|
122
|
+
# succeeds. A NULL state is reachable through an imported row,
|
|
123
|
+
# insert_all, or the documented `create!(status: nil)`.
|
|
124
|
+
eligible = eligible.where(arel_table[field].not_eq(to).or(arel_table[field].eq(nil)))
|
|
117
125
|
|
|
118
126
|
ConcernsOnRails::Support::BatchOps.run(
|
|
119
127
|
eligible,
|
|
@@ -225,10 +233,23 @@ module ConcernsOnRails
|
|
|
225
233
|
raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'" unless from.empty? || from.include?(current)
|
|
226
234
|
|
|
227
235
|
result = false
|
|
228
|
-
transaction
|
|
236
|
+
# requires_new: a bare `transaction` JOINS an enclosing one instead of
|
|
237
|
+
# opening a savepoint, so under a caller's transaction Rails swallowed
|
|
238
|
+
# an ActiveRecord::Rollback from after_transition and rolled nothing
|
|
239
|
+
# back — the state change committed and this returned true, exactly
|
|
240
|
+
# opposite to the documented contract above.
|
|
241
|
+
# Set AFTER after_transition, never from update! — the same reason
|
|
242
|
+
# Lockable's lockable_write_with_hooks flips `completed` only once the
|
|
243
|
+
# block has run to the end. Rails swallows ActiveRecord::Rollback at
|
|
244
|
+
# the savepoint boundary, so taking the return value from update!
|
|
245
|
+
# reported a fake success for a transition the hook had just aborted:
|
|
246
|
+
# `raise unless ticket.archive!` never fired, and transition_all
|
|
247
|
+
# counted a row it had rolled back.
|
|
248
|
+
transaction(requires_new: true) do
|
|
229
249
|
before_transition(event, current, to)
|
|
230
|
-
|
|
250
|
+
update!(field => to)
|
|
231
251
|
after_transition(event, current, to)
|
|
252
|
+
result = true
|
|
232
253
|
end
|
|
233
254
|
result
|
|
234
255
|
end
|
|
@@ -75,8 +75,17 @@ module ConcernsOnRails
|
|
|
75
75
|
# All distinct tags currently stored across the table, sorted.
|
|
76
76
|
# distinct + NULL filter dedupe DB-side, so identical tag strings ship
|
|
77
77
|
# over the wire once instead of once per row.
|
|
78
|
+
#
|
|
79
|
+
# reorder(nil) drops any inherited ORDER BY: PostgreSQL rejects
|
|
80
|
+
# SELECT DISTINCT ordered by a column outside the select list ("for
|
|
81
|
+
# SELECT DISTINCT, ORDER BY expressions must appear in select list"),
|
|
82
|
+
# and Models::Sortable installs exactly such a default_scope — so
|
|
83
|
+
# Taggable + Sortable raised on Postgres while passing on SQLite,
|
|
84
|
+
# which permits it. The ordering is meaningless here anyway: the
|
|
85
|
+
# result is sorted in Ruby below.
|
|
78
86
|
def all_tags
|
|
79
87
|
where.not(taggable_field => nil)
|
|
88
|
+
.reorder(nil)
|
|
80
89
|
.distinct
|
|
81
90
|
.pluck(taggable_field)
|
|
82
91
|
.flat_map { |raw| taggable_split(raw) }
|
|
@@ -39,29 +39,52 @@ module ConcernsOnRails
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Same contract, validated against another class (e.g. CounterCacheable
|
|
42
|
-
# checks the counter column on the *parent* model).
|
|
42
|
+
# checks the counter column on the *parent* model). Every missing column
|
|
43
|
+
# is reported in ONE error — a fresh model with five absent columns is one
|
|
44
|
+
# migration away, not five boot failures.
|
|
43
45
|
def ensure_columns_on!(concern, klass, *fields, types: nil)
|
|
44
46
|
return false unless schema_reachable?(klass)
|
|
45
47
|
|
|
46
|
-
fields.flatten.compact.
|
|
47
|
-
|
|
48
|
+
missing = fields.flatten.compact.map(&:to_sym).uniq.reject { |field| klass.column_names.include?(field.to_s) }
|
|
49
|
+
return true if missing.empty?
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
raise ArgumentError, missing_columns_message(concern, klass, missing, types)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# "Concern: 'a' does not exist in the database (table: t). Add it with: …"
|
|
55
|
+
# for one column; "'a', 'b' and 'c' do not exist … Add them with: …" for
|
|
56
|
+
# several. The singular wording is unchanged from earlier releases.
|
|
57
|
+
def missing_columns_message(concern, klass, missing, types)
|
|
58
|
+
quoted = missing.map { |field| "'#{field}'" }
|
|
59
|
+
subject = if quoted.size == 1
|
|
60
|
+
"#{quoted.first} does not exist"
|
|
61
|
+
else
|
|
62
|
+
"#{quoted[0..-2].join(', ')} and #{quoted.last} do not exist"
|
|
63
|
+
end
|
|
64
|
+
"#{concern}: #{subject} in the database (table: #{klass.table_name})." \
|
|
65
|
+
"#{column_migration_hint(klass, missing, types, concern: concern)}"
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
# " Add it with: bin/rails generate migration AddDeletedAtToArticles
|
|
57
69
|
# deleted_at:datetime" — every missing-column failure becomes a
|
|
58
|
-
# copy-paste fix.
|
|
59
|
-
# (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
# copy-paste fix. Several columns get ONE command, named after the
|
|
71
|
+
# concern (AddAddressableColumnsToUsers street:string city:string) since
|
|
72
|
+
# AddStreetAndCityAndZipAndCountryTo… stops being readable. Without a
|
|
73
|
+
# known type the column name goes out bare (the generator defaults to
|
|
74
|
+
# string). Accepts a single field or a list.
|
|
75
|
+
def column_migration_hint(klass, fields, types, concern: nil)
|
|
76
|
+
fields = Array(fields)
|
|
77
|
+
columns = fields.map do |field|
|
|
78
|
+
type = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
79
|
+
[field, type].compact.join(":")
|
|
80
|
+
end
|
|
81
|
+
table = klass.table_name.to_s.camelize
|
|
82
|
+
if fields.size == 1
|
|
83
|
+
" Add it with: bin/rails generate migration Add#{fields.first.to_s.camelize}To#{table} #{columns.first}"
|
|
84
|
+
else
|
|
85
|
+
" Add them with: bin/rails generate migration " \
|
|
86
|
+
"Add#{concern.to_s.demodulize}ColumnsTo#{table} #{columns.join(' ')}"
|
|
87
|
+
end
|
|
65
88
|
end
|
|
66
89
|
|
|
67
90
|
# True when the class's table can actually be inspected. Connection
|
|
@@ -85,6 +85,29 @@ module ConcernsOnRails
|
|
|
85
85
|
"could not decrypt value (wrong key or tampered ciphertext)"
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# True when `value` really is an envelope produced by #encrypt: strict
|
|
89
|
+
# Base64 decoding to at least header + IV + tag, carrying a version byte
|
|
90
|
+
# we recognize. Deliberately does NOT check the algorithm byte, so a
|
|
91
|
+
# future alg (0x11, deterministic) still reads as an envelope.
|
|
92
|
+
#
|
|
93
|
+
# Backs Encryptable#<field>_encrypted?, which used a bare `.present?` —
|
|
94
|
+
# true for plaintext too. Cheap: no key material, no crypto, no KDF.
|
|
95
|
+
def envelope?(value)
|
|
96
|
+
return false unless value.is_a?(String)
|
|
97
|
+
|
|
98
|
+
# "" for non-Base64 input, which then fails the length check below.
|
|
99
|
+
raw =
|
|
100
|
+
begin
|
|
101
|
+
value.unpack1("m0").to_s
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
""
|
|
104
|
+
end
|
|
105
|
+
return false if raw.bytesize < MIN_ENVELOPE_BYTES
|
|
106
|
+
|
|
107
|
+
version, = raw.byteslice(0, HEADER_LEN).unpack(HEADER_FORMAT)
|
|
108
|
+
version == VERSION_BYTE
|
|
109
|
+
end
|
|
110
|
+
|
|
88
111
|
# Deterministic keyed fingerprint (lowercase hex) for equality lookups — a
|
|
89
112
|
# "blind index". The HMAC key is domain-separated from the AES key via
|
|
90
113
|
# BLIND_INDEX_INFO, so the two are cryptographically independent. The same
|
|
@@ -10,13 +10,22 @@ module ConcernsOnRails
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
def render(controller, message:, status:, code: nil, details: nil)
|
|
13
|
-
|
|
13
|
+
# respond_to?(..., true) because render_error is very often declared
|
|
14
|
+
# under `private` — the idiomatic way to keep a controller helper from
|
|
15
|
+
# becoming a routable action — or exposed as a helper_method. The
|
|
16
|
+
# public-only check silently missed those and fell through to the
|
|
17
|
+
# inline body below, so an app rendering RFC 9457 problem+json got the
|
|
18
|
+
# gem's non-conforming shape for every Authorizable 403,
|
|
19
|
+
# WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400,
|
|
20
|
+
# with no error or warning. Authorizable already uses this spelling for
|
|
21
|
+
# current_user (`respond_to?(via, true)`) for exactly the same reason.
|
|
22
|
+
if controller.respond_to?(:render_error, true)
|
|
14
23
|
# errors: only when there are details — several concerns document the
|
|
15
24
|
# override contract as `render_error(message:, status:, code:)`, and
|
|
16
25
|
# an unconditional errors: kwarg would break those implementations.
|
|
17
26
|
kwargs = { message: message, code: code, status: status }
|
|
18
27
|
kwargs[:errors] = details if details
|
|
19
|
-
controller.render_error
|
|
28
|
+
controller.send(:render_error, **kwargs)
|
|
20
29
|
else
|
|
21
30
|
error = { message: message }
|
|
22
31
|
error[:code] = code if code
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.28.
|
|
4
|
+
version: 1.28.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|