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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +40 -8
- data/lib/concerns_on_rails/models/anonymizable.rb +1 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +151 -16
- data/lib/concerns_on_rails/support/column_guard.rb +38 -15
- 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: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
|
|
4
|
+
data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,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`.
|
|
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
|
|
@@ -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
|
-
|
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
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.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
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|