active_record-undo 0.1.1
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 +7 -0
- data/.rubocop.yml +44 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/DOCUMENTATION.md +261 -0
- data/LICENSE.txt +21 -0
- data/README.md +217 -0
- data/Rakefile +12 -0
- data/db/migrate/20260808000000_create_active_record_undo_tables.rb +16 -0
- data/lib/active_record/undo/cascade_handler/association_finder.rb +25 -0
- data/lib/active_record/undo/cascade_handler/record_updater.rb +21 -0
- data/lib/active_record/undo/cascade_handler.rb +44 -0
- data/lib/active_record/undo/engine.rb +19 -0
- data/lib/active_record/undo/model_extension.rb +85 -0
- data/lib/active_record/undo/undo_log.rb +21 -0
- data/lib/active_record/undo/undo_log_item.rb +56 -0
- data/lib/active_record/undo/version.rb +8 -0
- data/lib/active_record/undo.rb +31 -0
- data/sig/active_record/undo.rbs +6 -0
- metadata +173 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c421e5d58b60163d2abd5d6acc9033b9a96dbcdc0cd628b49d30bc0e0631d88c
|
|
4
|
+
data.tar.gz: f283ee35d356473309b5fbbcff68793765a4e307e31cbdcc82b5bd0c91b312d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0dfc203a6c9a27028cdddcdbec738a680ea177c3ada73c8e46f3ad836aa2c95402b985f78003ce74f1b421bf3ee84ee7e821ee2350d6eeb5fa5f03b15998fa13
|
|
7
|
+
data.tar.gz: d2d46f0384fbe4a0fe0ff8fd654732ca52405c90cd511eff2b9148a7b9aad83be94462927bfa46152ed3b07ecb1cb9015a8dc4804f96add8468d394ca8a03ff7
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
Exclude:
|
|
5
|
+
- 'bin/**/*'
|
|
6
|
+
- 'db/schema.rb'
|
|
7
|
+
- 'spec/internal/**/*'
|
|
8
|
+
- 'vendor/**/*'
|
|
9
|
+
|
|
10
|
+
Style/Documentation:
|
|
11
|
+
Enabled: false
|
|
12
|
+
|
|
13
|
+
Style/FrozenStringLiteralComment:
|
|
14
|
+
Enabled: true
|
|
15
|
+
|
|
16
|
+
Layout/LineLength:
|
|
17
|
+
Max: 120
|
|
18
|
+
|
|
19
|
+
Gemspec/DevelopmentDependencies:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
Metrics/BlockLength:
|
|
23
|
+
Exclude:
|
|
24
|
+
- 'spec/**/*'
|
|
25
|
+
- 'active_record-undo.gemspec'
|
|
26
|
+
|
|
27
|
+
Naming/VariableNumber:
|
|
28
|
+
Enabled: false
|
|
29
|
+
|
|
30
|
+
Metrics/MethodLength:
|
|
31
|
+
Max: 10
|
|
32
|
+
|
|
33
|
+
Metrics/AbcSize:
|
|
34
|
+
Max: 15
|
|
35
|
+
|
|
36
|
+
Metrics/CyclomaticComplexity:
|
|
37
|
+
Max: 7
|
|
38
|
+
Exclude:
|
|
39
|
+
- 'db/migrate/**/*'
|
|
40
|
+
|
|
41
|
+
Metrics/PerceivedComplexity:
|
|
42
|
+
Max: 7
|
|
43
|
+
Exclude:
|
|
44
|
+
- 'db/migrate/**/*'
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
* Public or private harassment
|
|
34
|
+
* Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at
|
|
63
|
+
[INSERT CONTACT METHOD].
|
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
+
|
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
+
reporter of any incident.
|
|
68
|
+
|
|
69
|
+
## Enforcement Guidelines
|
|
70
|
+
|
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
+
|
|
74
|
+
### 1. Correction
|
|
75
|
+
|
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
+
unprofessional or unwelcome in the community.
|
|
78
|
+
|
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
|
82
|
+
|
|
83
|
+
### 2. Warning
|
|
84
|
+
|
|
85
|
+
**Community Impact**: A violation through a single incident or series of
|
|
86
|
+
actions.
|
|
87
|
+
|
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
+
ban.
|
|
94
|
+
|
|
95
|
+
### 3. Temporary Ban
|
|
96
|
+
|
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
|
98
|
+
sustained inappropriate behavior.
|
|
99
|
+
|
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
+
communication with the community for a specified period of time. No public or
|
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
+
Violating these terms may lead to a permanent ban.
|
|
105
|
+
|
|
106
|
+
### 4. Permanent Ban
|
|
107
|
+
|
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
+
|
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
+
community.
|
|
114
|
+
|
|
115
|
+
## Attribution
|
|
116
|
+
|
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
+
version 2.1, available at
|
|
119
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
+
|
|
121
|
+
Community Impact Guidelines were inspired by
|
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
+
|
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
+
|
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
|
129
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# ActiveRecord::Undo Architecture & Technical Documentation
|
|
2
|
+
|
|
3
|
+
`ActiveRecord::Undo` is a Rails Engine gem providing transactional, cascade-aware soft deletion and restoration capabilities for ActiveRecord models. It records state transitions into a dedicated polymorphic audit structure within single database transactions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. System Architecture Overview
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
graph TD
|
|
11
|
+
SubGraphHostApp[Host Rails Application Models] -->|acts_as_undoable| ModelExt[ActiveRecord::Undo::ModelExtension]
|
|
12
|
+
|
|
13
|
+
subgraph GemCore[ActiveRecord::Undo Core Engine]
|
|
14
|
+
ModelExt -->|Calls soft_delete!| TxBoundary[ActiveRecord::Base.transaction]
|
|
15
|
+
|
|
16
|
+
subgraph TxBoundary
|
|
17
|
+
CreateLog[Create UndoLog Parent]
|
|
18
|
+
CreateLog --> Cascade[CascadeHandler Engine]
|
|
19
|
+
Cascade -->|Reflects Associations| AssociationLoop{Has Dependent Relations?}
|
|
20
|
+
AssociationLoop -->|Yes| RecursiveCall[Recurse soft_delete_cascade_internal!]
|
|
21
|
+
AssociationLoop -->|No / Completed| UpdateCol[update_columns timestamp]
|
|
22
|
+
UpdateCol --> AppendItem[Build UndoLogItem Record]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
TxBoundary --> LogResult[Return UndoLog Instance]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
subgraph DatabaseStorage[Persistence Layer]
|
|
29
|
+
AppendItem --> UndoLogTable[(undo_logs)]
|
|
30
|
+
AppendItem --> UndoItemTable[(undo_log_items)]
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Core Components & Responsibilities
|
|
37
|
+
|
|
38
|
+
### Component Summary
|
|
39
|
+
|
|
40
|
+
| Component | File Path | Class / Module | Core Responsibility |
|
|
41
|
+
| :--- | :--- | :--- | :--- |
|
|
42
|
+
| **Main Hook** | `lib/active_record/undo.rb` | `ActiveRecord::Undo` | Hooks into `ActiveSupport.on_load(:active_record)` |
|
|
43
|
+
| **Model Extension** | `lib/active_record/undo/model_extension.rb` | `ModelExtension` | Injects DSL (`acts_as_undoable`), scopes (`kept`, `soft_deleted`), and methods (`soft_delete!`) |
|
|
44
|
+
| **Cascade Engine** | `lib/active_record/undo/cascade_handler.rb` | `CascadeHandler` | Inspects ActiveRecord reflections (`reflections`) and executes DFS traversal |
|
|
45
|
+
| **Cascade Association Finder** | `lib/active_record/undo/cascade_handler/association_finder.rb` | `AssociationFinder` | Resolves which records should cascade based on dependency configuration |
|
|
46
|
+
| **Cascade Record Updater** | `lib/active_record/undo/cascade_handler/record_updater.rb` | `RecordUpdater` | Updates the database timestamps directly bypassing callbacks |
|
|
47
|
+
| **Audit Log Parent** | `lib/active_record/undo/undo_log.rb` | `UndoLog` | Represents the top-level deletion event and manages atomic batch restoration |
|
|
48
|
+
| **Audit Log Child** | `lib/active_record/undo/undo_log_item.rb` | `UndoLogItem` | Maps polymorphic targets (`item_type`, `item_id`) to original deleted entities |
|
|
49
|
+
| **Engine Link** | `lib/active_record/undo/engine.rb` | `Engine` | Appends `db/migrate/` directly to host app migration paths |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 3. Data Model & Database Design
|
|
54
|
+
|
|
55
|
+
```mermaid
|
|
56
|
+
erDiagram
|
|
57
|
+
UNDO_LOGS ||--|{ UNDO_LOG_ITEMS : "has_many"
|
|
58
|
+
UNDO_LOG_ITEMS }|--|| TARGET_MODEL : "belongs_to (polymorphic)"
|
|
59
|
+
|
|
60
|
+
UNDO_LOGS {
|
|
61
|
+
bigint id PK
|
|
62
|
+
datetime created_at
|
|
63
|
+
datetime updated_at
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
UNDO_LOG_ITEMS {
|
|
67
|
+
bigint id PK
|
|
68
|
+
bigint undo_log_id FK
|
|
69
|
+
string item_type
|
|
70
|
+
bigint item_id
|
|
71
|
+
datetime created_at
|
|
72
|
+
datetime updated_at
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
TARGET_MODEL {
|
|
76
|
+
bigint id PK
|
|
77
|
+
datetime deleted_at "or custom column"
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 4. Sequence Diagrams
|
|
84
|
+
|
|
85
|
+
### Soft Deletion Flow (`#soft_delete!`)
|
|
86
|
+
|
|
87
|
+
```mermaid
|
|
88
|
+
sequenceDiagram
|
|
89
|
+
autonumber
|
|
90
|
+
actor User
|
|
91
|
+
participant Model as Post Model
|
|
92
|
+
participant Ext as ModelExtension
|
|
93
|
+
participant Tx as DB Transaction
|
|
94
|
+
participant Log as UndoLog
|
|
95
|
+
participant Cascade as CascadeHandler
|
|
96
|
+
participant Child as Comment Model
|
|
97
|
+
|
|
98
|
+
User->>Model: post.soft_delete!
|
|
99
|
+
Model->>Ext: Check soft_deleted?
|
|
100
|
+
Ext-->>Model: false
|
|
101
|
+
Model->>Tx: Open ActiveRecord::Base.transaction
|
|
102
|
+
Tx->>Log: UndoLog.create!
|
|
103
|
+
Tx->>Cascade: CascadeHandler.new(post).soft_delete_with_cascade!
|
|
104
|
+
|
|
105
|
+
rect rgb(240, 240, 240)
|
|
106
|
+
note over Cascade, Child: Dynamic Association Reflection
|
|
107
|
+
Cascade->>Cascade: Inspect Post.reflections (:comments)
|
|
108
|
+
Cascade->>Child: Recurse soft_delete_cascade_internal!
|
|
109
|
+
Child->>Child: update_columns(deleted_at: timestamp)
|
|
110
|
+
Child->>Log: undo_log_items.build(item: comment_101)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
Cascade->>Model: update_columns(deleted_at: timestamp)
|
|
114
|
+
Cascade->>Log: undo_log_items.build(item: post)
|
|
115
|
+
Log->>Tx: undo_log.save!
|
|
116
|
+
Tx-->>Model: Commit Transaction
|
|
117
|
+
Model-->>User: Returns UndoLog Instance
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Restoration Flow (`#restore!`)
|
|
121
|
+
|
|
122
|
+
```mermaid
|
|
123
|
+
sequenceDiagram
|
|
124
|
+
autonumber
|
|
125
|
+
actor User
|
|
126
|
+
participant Log as UndoLog
|
|
127
|
+
participant Item as UndoLogItem
|
|
128
|
+
participant Model as Target Models
|
|
129
|
+
|
|
130
|
+
User->>Log: undo_log.restore!
|
|
131
|
+
Log->>Log: Open ActiveRecord::Base.transaction
|
|
132
|
+
|
|
133
|
+
rect rgb(240, 240, 240)
|
|
134
|
+
note over Log, Model: Reverse Order Processing (Bottom-Up)
|
|
135
|
+
Log->>Item: undo_log_items.reverse_each
|
|
136
|
+
Item->>Model: TargetClass.unscoped.find_by(id)
|
|
137
|
+
Item->>Model: target.update_columns(column_name => nil)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Log->>Log: destroy! (Deletes UndoLog & UndoLogItems)
|
|
141
|
+
Log-->>User: Restoration Complete
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 5. Key Technical Considerations
|
|
147
|
+
|
|
148
|
+
1. **Depth-First Traversal Order:** Cascading deletes traverse downward to child records before updating the parent node. Child item associations are appended to `undo_log_items` first, and the parent record is appended last.
|
|
149
|
+
2. **Reverse Restoration Order:** `#restore!` calls `undo_log_items.reverse_each`. This ensures the parent node is restored first before restoring its dependent records, maintaining database relational integrity.
|
|
150
|
+
3. **Bypassing Callbacks:** Soft-deletion updates use `update_columns`. This executes a direct SQL `UPDATE` query without firing standard ActiveRecord persistence callbacks (`save`, `validate`), preventing unintended side effects during soft deletes.
|
|
151
|
+
4. **Unscoped Model Resolution:** `#restore_item!` uses `klass.unscoped.find_by(id: item_id)` to locate records. This guarantees records are retrieved even when models define default scopes that filter out soft-deleted records.
|
|
152
|
+
5. **Class Inheritance Security Check:** When constantizing stored class strings, the gem validates that target models inherit from `ActiveRecord::Base` to prevent arbitrary non-model constant manipulation.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 6. Method-by-Method Implementation Reference
|
|
157
|
+
|
|
158
|
+
### 6.1 `lib/active_record/undo.rb` (Entrypoint)
|
|
159
|
+
|
|
160
|
+
* **`require 'active_record'` Loader Check**
|
|
161
|
+
- *Function*: Safely imports ActiveRecord. If ActiveRecord is not in the load path, it catches the `LoadError` and throws a detailed error instructing the developer to add `activerecord` to their `Gemfile`.
|
|
162
|
+
* **Loader Hook Block**
|
|
163
|
+
- *Function*: Detects if `ActiveSupport` is loaded:
|
|
164
|
+
- If present, registers `ActiveSupport.on_load(:active_record)` to inject the extension module when ActiveRecord boots.
|
|
165
|
+
- If absent (e.g. running in simple Ruby scripts), directly includes `ModelExtension` into `ActiveRecord::Base` as a fallback.
|
|
166
|
+
|
|
167
|
+
### 6.2 `lib/active_record/undo/engine.rb` (Rails Integration)
|
|
168
|
+
|
|
169
|
+
* **`initializer 'active_record_undo.migrations'`**
|
|
170
|
+
- *Function*: Automatically runs on Rails boot to append the gem's engine migrations directory to the host application's migrations search paths. This allows host applications to detect and run gem database migrations without needing to manually copy them into the application's workspace.
|
|
171
|
+
|
|
172
|
+
### 6.3 `lib/active_record/undo/model_extension.rb` (Model Extension Module)
|
|
173
|
+
|
|
174
|
+
* **`acts_as_undoable(column: :deleted_at)`**
|
|
175
|
+
- *Function*: Class-level DSL macro injected into models to enable soft-deletion.
|
|
176
|
+
- *Details*: Defines class-level configurations:
|
|
177
|
+
- `undoable_column`: Caches the name of the column (defaults to `:deleted_at`).
|
|
178
|
+
- `kept` scope: Returns records that are not soft-deleted (`where(column => nil)`).
|
|
179
|
+
- `soft_deleted` scope: Returns records that are soft-deleted (`where.not(column => nil)`).
|
|
180
|
+
* **`soft_deleted?`**
|
|
181
|
+
- *Function*: Checks if the current record instance has been soft-deleted. Returns `true` if the configured deletion column is populated with a timestamp.
|
|
182
|
+
* **`soft_delete!`**
|
|
183
|
+
- *Function*: Starts the cascade soft-deletion sequence for the record.
|
|
184
|
+
- *Steps*:
|
|
185
|
+
1. Calls `ensure_undoable_column_exists!` to verify the database column is present.
|
|
186
|
+
2. Aborts and returns `false` if the record is already soft-deleted.
|
|
187
|
+
3. Opens an ActiveRecord database transaction block.
|
|
188
|
+
4. Creates a new parent `UndoLog` object.
|
|
189
|
+
5. Recursively invokes cascading soft-deletes on associations and marks the record itself as soft-deleted via `CascadeHandler`.
|
|
190
|
+
6. Saves the transaction and returns the constructed `UndoLog`.
|
|
191
|
+
* **`restore!`**
|
|
192
|
+
- *Function*: Restores the record from its soft-deleted state.
|
|
193
|
+
- *Steps*:
|
|
194
|
+
1. Verifies column presence via `ensure_undoable_column_exists!`.
|
|
195
|
+
2. Aborts and returns `false` if the record is not soft-deleted.
|
|
196
|
+
3. Resolves the latest `UndoLogItem` that records the soft-deletion of this instance.
|
|
197
|
+
4. If a log item is found, it calls `restore!` on the parent `UndoLog` (which restores the entire deleted tree).
|
|
198
|
+
5. If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to `nil`.
|
|
199
|
+
* **`ensure_undoable_column_exists!` (Private)**
|
|
200
|
+
- *Function*: Asserts that the configured soft-delete column exists in the database schema table. Raises `ActiveRecord::Undo::Error` if missing.
|
|
201
|
+
* **`find_latest_undo_log_item` (Private)**
|
|
202
|
+
- *Function*: Queries `UndoLogItem` records pointing to this record, ordering by `created_at DESC` to find the most recent deletion event.
|
|
203
|
+
* **`soft_delete_cascade_internal!(timestamp, undo_log)` (Private)**
|
|
204
|
+
- *Function*: Wraps instantiation and invocation of `CascadeHandler` to encapsulate cascade traversal.
|
|
205
|
+
|
|
206
|
+
### 6.4 `lib/active_record/undo/cascade_handler.rb` (Cascade Execution)
|
|
207
|
+
|
|
208
|
+
* **`initialize(record)`**
|
|
209
|
+
- *Function*: Caches the record instance to be cascade deleted.
|
|
210
|
+
* **`soft_delete_with_cascade!(timestamp, undo_log)`**
|
|
211
|
+
- *Function*: Coordinates the cascade deletion of the current record.
|
|
212
|
+
- *Steps*:
|
|
213
|
+
1. Invokes `#cascade_to_associations!` to recurse into child tables.
|
|
214
|
+
2. Invokes `#update_record_timestamps!` to mark the current record as soft-deleted.
|
|
215
|
+
3. Appends an `UndoLogItem` pointing to this record to the `UndoLog` transaction.
|
|
216
|
+
* **`cascade_to_associations!(timestamp, undo_log)` (Private)**
|
|
217
|
+
- *Function*: Iterates over reflections retrieved by `AssociationFinder`, fetches their records, and calls `#cascade_to_record!` on each associated record.
|
|
218
|
+
* **`cascade_to_record!(associated, reflection, timestamp, undo_log)` (Private)**
|
|
219
|
+
- *Function*: Handles deletion of a single associated child record:
|
|
220
|
+
- Excludes it if it is already soft-deleted.
|
|
221
|
+
- If the child model is also configured with `acts_as_undoable`, calls its private `#soft_delete_cascade_internal!` recursively.
|
|
222
|
+
- If it is not undoable, but configured with `dependent: :destroy`, it invokes `#destroy!` to perform a hard-deletion.
|
|
223
|
+
|
|
224
|
+
### 6.5 `lib/active_record/undo/cascade_handler/association_finder.rb` (Reflections Finder)
|
|
225
|
+
|
|
226
|
+
* **`associations_to_cascade` (Private)**
|
|
227
|
+
- *Function*: Reflects on the model's association metadata and filters list of associations to select only those configured with `dependent: :destroy`, `dependent: :soft_delete`, or `dependent: :delete_all`.
|
|
228
|
+
* **`associated_records_for(reflection)` (Private)**
|
|
229
|
+
- *Function*: Fetches associated target records. Normalizes single relations and collection associations (like `has_many`) into a flat array structure.
|
|
230
|
+
|
|
231
|
+
### 6.6 `lib/active_record/undo/cascade_handler/record_updater.rb` (Timestamps Updater)
|
|
232
|
+
|
|
233
|
+
* **`update_record_timestamps!(timestamp)` (Private)**
|
|
234
|
+
- *Function*: Bypasses ActiveRecord validations, callbacks, and dirty checking to directly write updates for the soft-delete column and `updated_at` timestamps using database-level `update_columns`.
|
|
235
|
+
|
|
236
|
+
### 6.7 `lib/active_record/undo/undo_log.rb` (Batch Restoration)
|
|
237
|
+
|
|
238
|
+
* **`restore!`**
|
|
239
|
+
- *Function*: Triggers database restoration of the entire tree recorded under this log.
|
|
240
|
+
- *Steps*:
|
|
241
|
+
1. Opens a database transaction block.
|
|
242
|
+
2. Iterates over associated `undo_log_items` in *reverse order* (`reverse_each`), guaranteeing parent records are restored before child records.
|
|
243
|
+
3. Invokes `#restore_item!` on each item.
|
|
244
|
+
4. Automatically calls `#destroy!` on completion to purge the audit records (`UndoLog` and nested `UndoLogItem` rows) from the database.
|
|
245
|
+
|
|
246
|
+
### 6.8 `lib/active_record/undo/undo_log_item.rb` (Item Restoration)
|
|
247
|
+
|
|
248
|
+
* **`restore_item!`**
|
|
249
|
+
- *Function*: Performs restoration of the individual record referenced by the audit log item.
|
|
250
|
+
- *Steps*:
|
|
251
|
+
1. Resolves model class via `#resolve_model_class`.
|
|
252
|
+
2. Resolves target record using `unscoped.find_by(id: item_id)` (unscoping ignores default scopes filtering soft-deleted records).
|
|
253
|
+
3. Ensures the soft-delete column exists on the model table.
|
|
254
|
+
4. Resets the soft-delete column to `nil` using `#reset_soft_delete_column!`.
|
|
255
|
+
* **`resolve_model_class` (Private)**
|
|
256
|
+
- *Function*: Constantizes the stored `item_type` string.
|
|
257
|
+
- *Security*: Asserts that the constant is a valid class that inherits from `ActiveRecord::Base`. Raises `ActiveRecord::Undo::Error` if constantization fails or targets non-model classes.
|
|
258
|
+
* **`ensure_column_exists!(klass, column_name)` (Private)**
|
|
259
|
+
- *Function*: Confirms that the target soft-delete column exists in the class's table schema. Throws `ActiveRecord::Undo::Error` if missing.
|
|
260
|
+
* **`reset_soft_delete_column!(target, column_name)` (Private)**
|
|
261
|
+
- *Function*: Bypasses standard callbacks and validations to write a `nil` value to the soft-delete column directly in the database.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saurabh Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# ActiveRecord::Undo
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/active_record-undo)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
**ActiveRecord::Undo** brings transactional, cascade-aware soft deletes and automatic restores to Rails applications.
|
|
7
|
+
|
|
8
|
+
Unlike conventional soft-deletion gems, `active_record-undo` automatically captures an audit snapshot of all records affected across associations (e.g., dependent `has_many` or `has_one` relations) and tracks them in a dedicated polymorphic undo log. Restoring a deleted record cleanly restores its entire deleted child tree in a single atomic database transaction.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- 🔄 **Cascading Soft Deletes:** Soft deletes parent models along with dependent associations (`dependent: :destroy` / `:delete_all`).
|
|
15
|
+
- ⏪ **Atomic Restores:** Reverses soft deletion for an entire object tree (`undo_log.restore!`) within a single database transaction.
|
|
16
|
+
- ⚙️ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
|
|
17
|
+
- 📦 **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` models—no messy JSON payload parsing required.
|
|
18
|
+
- 🚂 **Zero Generator Setup:** Built on top of `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
|
|
19
|
+
- 🔍 **Default Scopes & Helpers:** Provides `.kept`, `.soft_deleted`, and `#soft_deleted?` query methods out of the box.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add this line to your application's `Gemfile`:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "active_record-undo"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then execute:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
$ bundle install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run database migrations. The gem automatically appends its tables (`undo_logs` and `undo_log_items`) to your app's migration path:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
$ rails db:migrate
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
*(Optional)* If you need to customize the migration, copy it to your host application's `db/migrate` folder:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
$ rails active_record_undo:install:migrations
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### 1. Database Setup
|
|
54
|
+
|
|
55
|
+
Ensure models using soft deletion have a timestamp column in their underlying database table:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
|
|
59
|
+
def change
|
|
60
|
+
add_column :posts, :deleted_at, :datetime
|
|
61
|
+
add_column :comments, :deleted_at, :datetime
|
|
62
|
+
add_column :archive_items, :archived_at, :datetime
|
|
63
|
+
|
|
64
|
+
add_index :posts, :deleted_at
|
|
65
|
+
add_index :comments, :deleted_at
|
|
66
|
+
add_index :archive_items, :archived_at
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 2. Configure Models
|
|
72
|
+
|
|
73
|
+
Add `acts_as_undoable` to models where you want soft deletion enabled. By default, it uses `:deleted_at`, but you can pass a custom column name:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class Post < ApplicationRecord
|
|
77
|
+
# Uses default :deleted_at column
|
|
78
|
+
acts_as_undoable
|
|
79
|
+
|
|
80
|
+
has_many :comments, dependent: :destroy
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class Comment < ApplicationRecord
|
|
84
|
+
# Uses default :deleted_at column
|
|
85
|
+
acts_as_undoable
|
|
86
|
+
|
|
87
|
+
belongs_to :post
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class ArchiveItem < ApplicationRecord
|
|
91
|
+
# Configured with a custom column
|
|
92
|
+
acts_as_undoable column: :archived_at
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick Start Guide
|
|
99
|
+
|
|
100
|
+
### Soft Delete & Cascade
|
|
101
|
+
|
|
102
|
+
Call `soft_delete!` on a record. It sets the configured soft-delete column across the record and its dependent relations inside a single transaction, returning an `ActiveRecord::Undo::UndoLog` instance:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
post = Post.find(1)
|
|
106
|
+
|
|
107
|
+
# Soft deletes post and all associated comments
|
|
108
|
+
undo_log = post.soft_delete!
|
|
109
|
+
|
|
110
|
+
post.soft_deleted? # => true
|
|
111
|
+
post.comments.kept.count # => 0
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Custom Column Usage
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
item = ArchiveItem.find(5)
|
|
118
|
+
item.soft_delete!
|
|
119
|
+
|
|
120
|
+
item.soft_deleted? # => true
|
|
121
|
+
item.archived_at # => 2026-08-08 22:20:16 UTC
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Inspect Deletion Logs
|
|
125
|
+
|
|
126
|
+
Inspect affected records through standard Rails associations on the returned `UndoLog`:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# List all items affected by this deletion event
|
|
130
|
+
undo_log.undo_log_items.map(&:item)
|
|
131
|
+
# => [#<Comment 101... id:>, #<Comment 102... id:>, #<Post 1... id:>]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Restoring Records
|
|
135
|
+
|
|
136
|
+
To restore a deleted object tree, you can invoke `restore!` either on the corresponding `UndoLog` or directly on the model instance itself:
|
|
137
|
+
|
|
138
|
+
#### Option A: Restore from the model instance (Recommended)
|
|
139
|
+
Calling `restore!` directly on the soft-deleted model automatically resolves its latest deletion event log, performs the cascading restore, and cleans up the log database rows:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# Restores the post and all comments deleted in the same batch
|
|
143
|
+
post.restore!
|
|
144
|
+
|
|
145
|
+
post.reload.soft_deleted? # => false
|
|
146
|
+
post.comments.count # => 2
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### Option B: Restore from the `UndoLog`
|
|
150
|
+
```ruby
|
|
151
|
+
# Reverses soft deletes for the post and comments
|
|
152
|
+
undo_log.restore!
|
|
153
|
+
|
|
154
|
+
post.reload.soft_deleted? # => false
|
|
155
|
+
post.comments.count # => 2
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Scopes & Querying
|
|
161
|
+
|
|
162
|
+
`ActiveRecord::Undo` provides scopes for filtering records based on the configured column:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# Fetch only active (non-deleted) records
|
|
166
|
+
Post.kept
|
|
167
|
+
|
|
168
|
+
# Fetch soft-deleted records
|
|
169
|
+
Post.soft_deleted
|
|
170
|
+
|
|
171
|
+
# Retrieve records including soft-deleted ones via unscoped
|
|
172
|
+
Post.unscoped.where(id: 1)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## How It Works
|
|
178
|
+
|
|
179
|
+
1. **Cascade Inspection:** When `soft_delete!` is called, `ActiveRecord::Undo::CascadeHandler` reflects on `has_many`, `has_one`, and `belongs_to` associations configured with `dependent: :destroy` or `:delete_all`.
|
|
180
|
+
2. **Dynamic Column Resolution:** The handler checks `record.class.undoable_column` to set the correct timestamp column (`:deleted_at`, `:archived_at`, etc.) across all affected models.
|
|
181
|
+
3. **Polymorphic Logging:** An `ActiveRecord::Undo::UndoLog` record is created alongside multiple `ActiveRecord::Undo::UndoLogItem` entries mapping polymorphic references (`item_type`, `item_id`) to every affected record.
|
|
182
|
+
4. **Atomic Operation:** All updates and log creations take place within an `ActiveRecord::Base.transaction`.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Error Handling
|
|
187
|
+
|
|
188
|
+
To ensure database integrity and provide clear debugging context, the gem raises an `ActiveRecord::Undo::Error` in the following scenarios:
|
|
189
|
+
* **Missing Column at Runtime:** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
|
|
190
|
+
* **Missing Model Class:** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
|
|
191
|
+
* **Missing Column on Target Class:** If a model class exists but no longer has the target soft-delete column during restore.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
After cloning the repository, install dependencies:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
$ bundle install
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Run test suite via RSpec:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
$ bundle exec rspec
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Contributing
|
|
212
|
+
|
|
213
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo.
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# db/migrate/20260808000000_create_active_record_undo_tables.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class CreateActiveRecordUndoTables < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
create_table :undo_logs, &:timestamps
|
|
7
|
+
|
|
8
|
+
create_table :undo_log_items do |t|
|
|
9
|
+
t.references :undo_log, null: false, foreign_key: { to_table: :undo_logs }
|
|
10
|
+
t.references :item, polymorphic: true, null: false
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :undo_log_items, %i[item_type item_id]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# lib/active_record/undo/cascade_handler/association_finder.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Undo
|
|
6
|
+
class CascadeHandler
|
|
7
|
+
module AssociationFinder
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def associations_to_cascade
|
|
11
|
+
@record.class.reflections.values.select do |reflection|
|
|
12
|
+
%i[destroy soft_delete delete_all].include?(reflection.options[:dependent])
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def associated_records_for(reflection)
|
|
17
|
+
target = @record.public_send(reflection.name)
|
|
18
|
+
return [] if target.nil?
|
|
19
|
+
|
|
20
|
+
target.is_a?(Enumerable) ? target : [target]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# lib/active_record/undo/cascade_handler/record_updater.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Undo
|
|
6
|
+
class CascadeHandler
|
|
7
|
+
module RecordUpdater
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def update_record_timestamps!(timestamp)
|
|
11
|
+
column_name = @record.class.respond_to?(:undoable_column) ? @record.class.undoable_column : :deleted_at
|
|
12
|
+
|
|
13
|
+
@record.update_columns(
|
|
14
|
+
column_name => timestamp,
|
|
15
|
+
updated_at: timestamp
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# lib/active_record/undo/cascade_handler.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'cascade_handler/association_finder'
|
|
5
|
+
require_relative 'cascade_handler/record_updater'
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module Undo
|
|
9
|
+
class CascadeHandler
|
|
10
|
+
include AssociationFinder
|
|
11
|
+
include RecordUpdater
|
|
12
|
+
|
|
13
|
+
def initialize(record)
|
|
14
|
+
@record = record
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def soft_delete_with_cascade!(timestamp, undo_log)
|
|
18
|
+
cascade_to_associations!(timestamp, undo_log)
|
|
19
|
+
update_record_timestamps!(timestamp)
|
|
20
|
+
undo_log.undo_log_items.build(item: @record)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def cascade_to_associations!(timestamp, undo_log)
|
|
26
|
+
associations_to_cascade.each do |reflection|
|
|
27
|
+
associated_records_for(reflection).each do |associated|
|
|
28
|
+
cascade_to_record!(associated, reflection, timestamp, undo_log)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cascade_to_record!(associated, reflection, timestamp, undo_log)
|
|
34
|
+
return if associated.respond_to?(:soft_deleted?) && associated.soft_deleted?
|
|
35
|
+
|
|
36
|
+
if associated.respond_to?(:soft_delete_cascade_internal!, true)
|
|
37
|
+
associated.send(:soft_delete_cascade_internal!, timestamp, undo_log)
|
|
38
|
+
elsif reflection.options[:dependent] == :destroy
|
|
39
|
+
associated.destroy!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# lib/active_record/undo/engine.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Undo
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace ActiveRecord::Undo
|
|
8
|
+
|
|
9
|
+
# Automatically appends gem migrations to the host application's migration path
|
|
10
|
+
initializer 'active_record_undo.migrations' do |app|
|
|
11
|
+
unless app.root.to_s.match?(root.to_s)
|
|
12
|
+
config.paths['db/migrate'].expanded.each do |expanded_path|
|
|
13
|
+
app.config.paths['db/migrate'] << expanded_path
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# lib/active_record/undo/model_extension.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'cascade_handler'
|
|
5
|
+
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
module Undo
|
|
8
|
+
module ModelExtension
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
# Accept a custom column parameter, defaulting to :deleted_at
|
|
13
|
+
def acts_as_undoable(column: :deleted_at)
|
|
14
|
+
include InstanceMethods
|
|
15
|
+
|
|
16
|
+
class_attribute :undoable_column
|
|
17
|
+
self.undoable_column = column.to_sym
|
|
18
|
+
|
|
19
|
+
# Dynamic scopes using the configured column
|
|
20
|
+
scope :kept, -> { where(undoable_column => nil) }
|
|
21
|
+
scope :soft_deleted, -> { where.not(undoable_column => nil) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module InstanceMethods
|
|
26
|
+
def soft_deleted?
|
|
27
|
+
public_send(self.class.undoable_column).present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def soft_delete!
|
|
31
|
+
ensure_undoable_column_exists!
|
|
32
|
+
return false if soft_deleted?
|
|
33
|
+
|
|
34
|
+
undo_log = nil
|
|
35
|
+
timestamp = Time.current
|
|
36
|
+
|
|
37
|
+
transaction do
|
|
38
|
+
undo_log = ActiveRecord::Undo::UndoLog.create!
|
|
39
|
+
soft_delete_cascade_internal!(timestamp, undo_log)
|
|
40
|
+
undo_log.save!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
undo_log
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def restore!
|
|
47
|
+
ensure_undoable_column_exists!
|
|
48
|
+
return false unless soft_deleted?
|
|
49
|
+
|
|
50
|
+
log_item = find_latest_undo_log_item
|
|
51
|
+
|
|
52
|
+
if log_item
|
|
53
|
+
log_item.undo_log.restore!
|
|
54
|
+
else
|
|
55
|
+
column_name = self.class.undoable_column
|
|
56
|
+
update_columns(column_name => nil, updated_at: Time.current)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def ensure_undoable_column_exists!
|
|
64
|
+
column_name = self.class.undoable_column
|
|
65
|
+
return if self.class.column_names.include?(column_name.to_s)
|
|
66
|
+
|
|
67
|
+
raise ActiveRecord::Undo::Error,
|
|
68
|
+
"The configured soft-delete column '#{column_name}' " \
|
|
69
|
+
"does not exist on table '#{self.class.table_name}'."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_latest_undo_log_item
|
|
73
|
+
ActiveRecord::Undo::UndoLogItem
|
|
74
|
+
.where(item: self)
|
|
75
|
+
.order(created_at: :desc)
|
|
76
|
+
.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def soft_delete_cascade_internal!(timestamp, undo_log)
|
|
80
|
+
CascadeHandler.new(self).soft_delete_with_cascade!(timestamp, undo_log)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# lib/active_record/undo/undo_log.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Undo
|
|
6
|
+
class UndoLog < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'undo_logs'
|
|
8
|
+
|
|
9
|
+
has_many :undo_log_items, class_name: 'ActiveRecord::Undo::UndoLogItem', dependent: :destroy
|
|
10
|
+
|
|
11
|
+
# Restores all records associated with this deletion batch
|
|
12
|
+
def restore!
|
|
13
|
+
transaction do
|
|
14
|
+
# Reverse order ensures child records are restored before or after parents as needed
|
|
15
|
+
undo_log_items.reverse_each(&:restore_item!)
|
|
16
|
+
destroy! # Clean up log after successful restoration
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# lib/active_record/undo/undo_log_item.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Undo
|
|
6
|
+
class UndoLogItem < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'undo_log_items'
|
|
8
|
+
|
|
9
|
+
belongs_to :undo_log, class_name: 'ActiveRecord::Undo::UndoLog'
|
|
10
|
+
belongs_to :item, polymorphic: true
|
|
11
|
+
|
|
12
|
+
def restore_item!
|
|
13
|
+
klass = resolve_model_class
|
|
14
|
+
target = klass.unscoped.find_by(id: item_id)
|
|
15
|
+
return unless target
|
|
16
|
+
|
|
17
|
+
column_name = klass.respond_to?(:undoable_column) ? klass.undoable_column : :deleted_at
|
|
18
|
+
ensure_column_exists!(klass, column_name)
|
|
19
|
+
|
|
20
|
+
return unless target.public_send(column_name).present?
|
|
21
|
+
|
|
22
|
+
reset_soft_delete_column!(target, column_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def resolve_model_class
|
|
28
|
+
klass = item_type.constantize
|
|
29
|
+
unless klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
30
|
+
raise ActiveRecord::Undo::Error,
|
|
31
|
+
"Cannot restore item: '#{item_type}' is not an ActiveRecord model."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
klass
|
|
35
|
+
rescue NameError => e
|
|
36
|
+
raise ActiveRecord::Undo::Error,
|
|
37
|
+
"Cannot restore item: model class '#{item_type}' could not be loaded. " \
|
|
38
|
+
"Original error: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ensure_column_exists!(klass, column_name)
|
|
42
|
+
return if klass.column_names.include?(column_name.to_s)
|
|
43
|
+
|
|
44
|
+
raise ActiveRecord::Undo::Error,
|
|
45
|
+
"Cannot restore item: column '#{column_name}' does not exist on '#{item_type}' model."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_soft_delete_column!(target, column_name)
|
|
49
|
+
target.update_columns(
|
|
50
|
+
column_name => nil,
|
|
51
|
+
updated_at: Time.current
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# lib/active_record/undo.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
require 'active_record'
|
|
6
|
+
rescue LoadError => e
|
|
7
|
+
raise LoadError,
|
|
8
|
+
'active_record-undo requires ActiveRecord. ' \
|
|
9
|
+
"Please add 'activerecord' to your Gemfile. (Original error: #{e.message})"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative 'undo/version'
|
|
13
|
+
require_relative 'undo/engine' if defined?(Rails::Engine)
|
|
14
|
+
require_relative 'undo/model_extension'
|
|
15
|
+
require_relative 'undo/undo_log'
|
|
16
|
+
require_relative 'undo/undo_log_item'
|
|
17
|
+
|
|
18
|
+
module ActiveRecord
|
|
19
|
+
module Undo
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if defined?(ActiveSupport)
|
|
25
|
+
ActiveSupport.on_load(:active_record) do
|
|
26
|
+
include ActiveRecord::Undo::ModelExtension
|
|
27
|
+
end
|
|
28
|
+
elsif defined?(ActiveRecord::Base)
|
|
29
|
+
# Fallback for bare Ruby scripts where ActiveSupport hooks aren't initialized yet
|
|
30
|
+
ActiveRecord::Base.include(ActiveRecord::Undo::ModelExtension)
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_record-undo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Saurabh Sharma
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.0.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 7.0.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: bundler
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: combustion
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.3'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.3'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rspec
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rspec-rails
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rubocop
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '1.21'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '1.21'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: sqlite3
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '1.4'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '1.4'
|
|
124
|
+
description: Provides soft deletion and automatic restoration of records and their
|
|
125
|
+
dependent associations.
|
|
126
|
+
email:
|
|
127
|
+
- saurabh@activecode.in
|
|
128
|
+
executables: []
|
|
129
|
+
extensions: []
|
|
130
|
+
extra_rdoc_files: []
|
|
131
|
+
files:
|
|
132
|
+
- ".rubocop.yml"
|
|
133
|
+
- CHANGELOG.md
|
|
134
|
+
- CODE_OF_CONDUCT.md
|
|
135
|
+
- DOCUMENTATION.md
|
|
136
|
+
- LICENSE.txt
|
|
137
|
+
- README.md
|
|
138
|
+
- Rakefile
|
|
139
|
+
- db/migrate/20260808000000_create_active_record_undo_tables.rb
|
|
140
|
+
- lib/active_record/undo.rb
|
|
141
|
+
- lib/active_record/undo/cascade_handler.rb
|
|
142
|
+
- lib/active_record/undo/cascade_handler/association_finder.rb
|
|
143
|
+
- lib/active_record/undo/cascade_handler/record_updater.rb
|
|
144
|
+
- lib/active_record/undo/engine.rb
|
|
145
|
+
- lib/active_record/undo/model_extension.rb
|
|
146
|
+
- lib/active_record/undo/undo_log.rb
|
|
147
|
+
- lib/active_record/undo/undo_log_item.rb
|
|
148
|
+
- lib/active_record/undo/version.rb
|
|
149
|
+
- sig/active_record/undo.rbs
|
|
150
|
+
homepage: https://github.com/saurabh-activecode/active_record-undo
|
|
151
|
+
licenses:
|
|
152
|
+
- MIT
|
|
153
|
+
metadata:
|
|
154
|
+
homepage_uri: https://github.com/saurabh-activecode/active_record-undo
|
|
155
|
+
rubygems_mfa_required: 'true'
|
|
156
|
+
rdoc_options: []
|
|
157
|
+
require_paths:
|
|
158
|
+
- lib
|
|
159
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
160
|
+
requirements:
|
|
161
|
+
- - ">="
|
|
162
|
+
- !ruby/object:Gem::Version
|
|
163
|
+
version: 3.1.0
|
|
164
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
165
|
+
requirements:
|
|
166
|
+
- - ">="
|
|
167
|
+
- !ruby/object:Gem::Version
|
|
168
|
+
version: '0'
|
|
169
|
+
requirements: []
|
|
170
|
+
rubygems_version: 3.6.9
|
|
171
|
+
specification_version: 4
|
|
172
|
+
summary: Soft-delete extension for ActiveRecord with transactional cascade undo tracking.
|
|
173
|
+
test_files: []
|