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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0a05169c98ce90531027509dbbdd4f00722678e2cfe246a5692484d9a12a111
4
- data.tar.gz: 10d2cebdced9beed7b677b18a0b8c85dc82d1125672ada2f5c5f12b995fa1933
3
+ metadata.gz: 615b4ddcff3ebdffc7e32f618731510bf5e386fa015cb0a1ae958a4e6fb04d1c
4
+ data.tar.gz: 723b3ae2077f43fc5771947d2e610f011ca83ee8215b78fb0894a04400362220
5
5
  SHA512:
6
- metadata.gz: 756646b2232fa428250bf502fbb7ce87a44bf39830d9ae4810e50482315394fa1aa0b0e1ed47fb9a4b87d677d11507adfb7e14206f0b27119235060c74f1513b
7
- data.tar.gz: '08b14db3a23711b0531447c773b2d831ec37fcf8c2b84fa59b4f203c7375923297e3a9dd93597b6e0091f18e677752be0dca67b913a4a3b1dd61fb042b417fad'
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 column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
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,360 RSpec examples** run against a real database on every CI build
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`. Note that `really_destroy_all` peels the soft-delete
475
- predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
476
- relationpurge trash with `User.soft_deleted.delete_all` instead.
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
- | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
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,360 examples)
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.2.gem # install locally
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
- when ActiveSupport::TimeWithZone, Time then value.utc
145
- when DateTime then value.to_time.utc
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
- return unless respond_to?(:render_error) || (respond_to?(:response) && response)
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. Blank values
46
- # are skipped so unset filters don't narrow the relation.
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.blank?
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
- relation.public_send(options[:scope])
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.to_i
63
- self.paginatable_max_per_page = max_per_page.to_i
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
- [ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0), 1].max
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
- cap.positive? ? [requested, cap].min : requested
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
- return unless respond_to?(:response) && response
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
- action = respond_to?(:action_name) ? action_name.to_s : nil
191
- return nil unless action
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
- self.class.webhook_rules.find { |rule| rule[:actions].empty? || rule[:actions].include?(action) }
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
- # Raw stored value: the DB ciphertext once persisted (before the type
275
- # deserializes it). Useful for migrations, debugging, and asserting no
276
- # plaintext is at rest.
277
- define_method("#{field}_ciphertext") { read_attribute_before_type_cast(field) }
278
- define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
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 do
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
- def soft_deletable_by(field = nil, touch: true, default_scope: true, prefix: nil, suffix: nil)
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 — including
86
- # soft-deleted rows (only the soft-delete column's predicates are
87
- # peeled off). Note that `unscope` also drops a caller's own condition
88
- # on that column, so `only_deleted.really_destroy_all` widens to the
89
- # whole relation — use `soft_deleted.delete_all` to purge trash only.
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
- all.unscope(where: soft_delete_field).delete_all
109
+ soft_delete_without_default_scope.delete_all
94
110
  end
95
111
 
96
- # Restore every soft-deleted record (mirror of soft_delete_all):
97
- # Integer count, RecordNotSaved + rollback on failure, single UPDATE
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 = all.public_send(soft_delete_scope_names.fetch(:soft_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
- def soft_delete!
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 => Time.zone.now)
265
+ update(self.class.soft_delete_field => at)
182
266
  else
183
- update_column(self.class.soft_delete_field, Time.zone.now)
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
- eligible = eligible.where.not(field => to)
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 do
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
- result = update!(field => to)
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.each do |field|
47
- next if klass.column_names.include?(field.to_s)
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
- raise ArgumentError,
50
- "#{concern}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
51
- "#{column_migration_hint(klass, field, types)}"
52
- end
53
- true
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. Without a known type the column name goes out bare
59
- # (the generator defaults to string).
60
- def column_migration_hint(klass, field, types)
61
- type = types.is_a?(Hash) ? types[field.to_sym] : types
62
- column = [field, type].compact.join(":")
63
- " Add it with: bin/rails generate migration " \
64
- "Add#{field.to_s.camelize}To#{klass.table_name.to_s.camelize} #{column}"
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
- if controller.respond_to?(:render_error)
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(**kwargs)
28
+ controller.send(:render_error, **kwargs)
20
29
  else
21
30
  error = { message: message }
22
31
  error[:code] = code if code
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.2".freeze
2
+ VERSION = "1.28.4".freeze
3
3
  end
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.2
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 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack