concerns_on_rails 1.28.2 → 1.28.3

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: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
4
+ data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
5
5
  SHA512:
6
- metadata.gz: 756646b2232fa428250bf502fbb7ce87a44bf39830d9ae4810e50482315394fa1aa0b0e1ed47fb9a4b87d677d11507adfb7e14206f0b27119235060c74f1513b
7
- data.tar.gz: '08b14db3a23711b0531447c773b2d831ec37fcf8c2b84fa59b4f203c7375923297e3a9dd93597b6e0091f18e677752be0dca67b913a4a3b1dd61fb042b417fad'
6
+ metadata.gz: 98d77a84da148195bdd1c013054b12bd3b8c4f6ce2bf5b7ae8ee7e91c0a24b4d5d45e6540c139456e1d51b91afbfaed5cf68d9faf9859399cb4ec2b76ccdd90b
7
+ data.tar.gz: 6852cb66a64150139c9625d765cd3f724881a6b35a9e5920140713c21b69f148c956353d587765f3ad1eca6018b38de70b98aa8aa9c40006ad0a6919242607c8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.3 (2026-09-16)
4
+
5
+ Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
6
+ the maintainer's request: a SoftDeletable bug fix (`restore_all` /
7
+ `really_destroy_all` dropped the caller's own predicate on the soft-delete
8
+ column), a ColumnGuard change (every missing column reported in one error with
9
+ one migration command) and an additive SoftDeletable `cascade:` option for
10
+ has_many / has_one dependents. No new migrations or runtime dependencies.
11
+ 1396 examples, 0 failures.
12
+
13
+ ### Fixed
14
+ - **Models::SoftDeletable**: `restore_all` and `really_destroy_all` now honour a
15
+ caller's predicate on the soft-delete column. Both used to `unscope` the
16
+ column outright to peel off the default scope's `deleted_at IS NULL`, which
17
+ also dropped `deleted_within(1.hour)` / `where(deleted_at: range)` /
18
+ `only_deleted` — so `User.deleted_within(1.hour).restore_all` restored the
19
+ whole trash can and `only_deleted.really_destroy_all` widened to the whole
20
+ relation. Only the default scope's own predicate is peeled now; predicates on
21
+ other columns (a host model's own `default_scope` included) are untouched. The
22
+ scopes themselves still unscope the column: chain `soft_deleted.where(...)`,
23
+ not `where(...).soft_deleted`. (#41)
24
+ - **Models::Anonymizable**: the stamp column's migration hint now carries its
25
+ type (`anonymized_at:datetime`). (#43)
26
+
27
+ ### Changed
28
+ - **Support::ColumnGuard**: a macro that finds several missing columns now
29
+ reports them all in one `ArgumentError` — `'street', 'city' and 'zip' do not
30
+ exist …` — with a single combined migration command
31
+ (`bin/rails generate migration AddAddressableColumnsToUsers street:string
32
+ city:string zip:string`) instead of failing boot once per column. Single-
33
+ column wording and generator name are unchanged. (#43)
34
+
35
+ ### Added
36
+ - **Models::SoftDeletable**: `soft_deletable_by … cascade: %i[comments cover]`
37
+ soft-deletes has_many / has_one dependents with the record (same
38
+ transaction, same timestamp, through their own `soft_delete!` so hooks and
39
+ nested cascades run) and restores exactly those on `restore!` — a dependent
40
+ deleted independently earlier stays deleted. New `soft_delete!(at:)` keyword.
41
+ With a cascade configured, `soft_delete_all` / `restore_all` take the
42
+ per-record path. `belongs_to`, HABTM and `:through` are rejected at class
43
+ load; the target model must include SoftDeletable (checked at class load when
44
+ it already resolves, otherwise on the first cascade). (#52)
45
+
46
+ ### Notes
47
+ `cascade:` is off by default — models without it behave exactly as before.
48
+ `restore!` matches cascaded dependents by the parent's exact `deleted_at`, so
49
+ give the columns `precision: 6` (the Rails 7 default) if two parents may be
50
+ deleted within one second. With a cascade configured the single-`UPDATE` fast
51
+ paths of `soft_delete_all` / `restore_all` are disabled (a bulk `UPDATE` cannot
52
+ follow associations). Anything matching `/does not exist/` on a one-column
53
+ ColumnGuard failure still matches — only the several-columns wording and
54
+ generator name changed. The README's "use instead" table no longer lists
55
+ association-cascade soft delete as a reason to reach for paranoia / discard.
56
+
3
57
  ## 1.28.2 (2026-09-11)
4
58
 
5
59
  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,396 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
@@ -2183,7 +2215,7 @@ Both forms reference the same module, so you can freely mix them.
2183
2215
  | Need | Use instead |
2184
2216
  |------|-------------|
2185
2217
  | 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) |
2218
+ | 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
2219
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
2188
2220
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2189
2221
  | 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 +2249,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2217
2249
 
2218
2250
  ```sh
2219
2251
  bundle install # install dev dependencies
2220
- bundle exec rspec # run the test suite (1,360 examples)
2252
+ bundle exec rspec # run the test suite (1,396 examples)
2221
2253
  gem build concerns_on_rails.gemspec # build the gem
2222
- gem install ./concerns_on_rails-1.28.2.gem # install locally
2254
+ gem install ./concerns_on_rails-1.28.3.gem # install locally
2223
2255
 
2224
2256
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2225
2257
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -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)
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.2".freeze
2
+ VERSION = "1.28.3".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.3
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