active_record-undo 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/DOCUMENTATION.md +68 -4
- data/README.md +103 -7
- data/lib/active_record/undo/configuration.rb +13 -0
- data/lib/active_record/undo/engine.rb +5 -0
- data/lib/active_record/undo/model_extension.rb +61 -11
- data/lib/active_record/undo/purge_job.rb +21 -0
- data/lib/active_record/undo/purger.rb +107 -0
- data/lib/active_record/undo/tasks/purge.rake +9 -0
- data/lib/active_record/undo/undo_log.rb +9 -0
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo.rb +37 -0
- data/lib/tasks/active_record_undo_tasks.rake +4 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aedb1b7356b0530c7316c1c94a4f2681cf4c103cb50986e24dfaa1f5d1eec553
|
|
4
|
+
data.tar.gz: 33f779d1c31b5c80126fa4838b8f080f512e25dca9dd372d88e3ff1d9a1b4eaa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 982b77e10592f7e0599d080b74d5c9daa4658c9df14e8a068b95dd00d51cacd33e813e5250e88b3fedc134fbfbe66fde57a6ad62c089b0dd2ce32a56c2597332
|
|
7
|
+
data.tar.gz: 177f71075ba0e65a2e372fbfc9afb4783248c29eb164955cf7449706257e82759d38e86b0eefad18b89a4b4a17ee335c8d589be65b6d288ff0126ccf9fafd7ab
|
data/DOCUMENTATION.md
CHANGED
|
@@ -12,6 +12,7 @@ graph TD
|
|
|
12
12
|
|
|
13
13
|
subgraph GemCore[ActiveRecord::Undo Core Engine]
|
|
14
14
|
ModelExt -->|Calls soft_delete!| TxBoundary[ActiveRecord::Base.transaction]
|
|
15
|
+
ModelExt -->|Calls undoable?| CheckQuery[SQL EXISTS Query across UndoLogItem & UndoLog]
|
|
15
16
|
|
|
16
17
|
subgraph TxBoundary
|
|
17
18
|
CreateLog[Create UndoLog Parent]
|
|
@@ -28,6 +29,7 @@ graph TD
|
|
|
28
29
|
subgraph DatabaseStorage[Persistence Layer]
|
|
29
30
|
AppendItem --> UndoLogTable[(undo_logs)]
|
|
30
31
|
AppendItem --> UndoItemTable[(undo_log_items)]
|
|
32
|
+
CheckQuery --> UndoItemTable
|
|
31
33
|
end
|
|
32
34
|
```
|
|
33
35
|
|
|
@@ -40,13 +42,17 @@ graph TD
|
|
|
40
42
|
| Component | File Path | Class / Module | Core Responsibility |
|
|
41
43
|
| :--- | :--- | :--- | :--- |
|
|
42
44
|
| **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!`) |
|
|
45
|
+
| **Model Extension** | `lib/active_record/undo/model_extension.rb` | `ModelExtension` | Injects DSL (`acts_as_undoable`), scopes (`kept`, `soft_deleted`), and methods (`soft_delete!`, `undoable?`, `restore!`) |
|
|
44
46
|
| **Cascade Engine** | `lib/active_record/undo/cascade_handler.rb` | `CascadeHandler` | Inspects ActiveRecord reflections (`reflections`) and executes DFS traversal |
|
|
45
47
|
| **Cascade Association Finder** | `lib/active_record/undo/cascade_handler/association_finder.rb` | `AssociationFinder` | Resolves which records should cascade based on dependency configuration |
|
|
46
48
|
| **Cascade Record Updater** | `lib/active_record/undo/cascade_handler/record_updater.rb` | `RecordUpdater` | Updates the database timestamps directly bypassing callbacks |
|
|
47
49
|
| **Audit Log Parent** | `lib/active_record/undo/undo_log.rb` | `UndoLog` | Represents the top-level deletion event and manages atomic batch restoration |
|
|
48
50
|
| **Audit Log Child** | `lib/active_record/undo/undo_log_item.rb` | `UndoLogItem` | Maps polymorphic targets (`item_type`, `item_id`) to original deleted entities |
|
|
49
51
|
| **Engine Link** | `lib/active_record/undo/engine.rb` | `Engine` | Appends `db/migrate/` directly to host app migration paths |
|
|
52
|
+
| **Configuration** | `lib/active_record/undo/configuration.rb` | `Configuration` | Houses retention_period settings |
|
|
53
|
+
| **Purger Service** | `lib/active_record/undo/purger.rb` | `Purger` | Deletes expired UndoLog/Items and soft-deleted model records in batches |
|
|
54
|
+
| **Purge Job** | `lib/active_record/undo/purge_job.rb` | `PurgeJob` | ActiveJob background runner invoking Purger |
|
|
55
|
+
| **Rake Task** | `lib/active_record/undo/tasks/purge.rake` | Rake Task | Exposes `active_record_undo:purge_expired` command |
|
|
50
56
|
|
|
51
57
|
---
|
|
52
58
|
|
|
@@ -117,6 +123,28 @@ sequenceDiagram
|
|
|
117
123
|
Model-->>User: Returns UndoLog Instance
|
|
118
124
|
```
|
|
119
125
|
|
|
126
|
+
### Restoration Verification Flow (`#undoable?`)
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
sequenceDiagram
|
|
130
|
+
autonumber
|
|
131
|
+
actor User
|
|
132
|
+
participant Model as Post Model
|
|
133
|
+
participant Ext as ModelExtension
|
|
134
|
+
participant DB as SQL Engine
|
|
135
|
+
|
|
136
|
+
User->>Model: post.undoable?
|
|
137
|
+
Model->>Ext: Check soft_deleted?
|
|
138
|
+
|
|
139
|
+
alt Not Soft Deleted
|
|
140
|
+
Ext-->>User: returns false
|
|
141
|
+
else Is Soft Deleted
|
|
142
|
+
Ext->>DB: SELECT 1 FROM undo_log_items INNER JOIN undo_logs ... LIMIT 1
|
|
143
|
+
DB-->>Ext: Record Exists (true / false)
|
|
144
|
+
Ext-->>User: returns boolean
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
120
148
|
### Restoration Flow (`#restore!`)
|
|
121
149
|
|
|
122
150
|
```mermaid
|
|
@@ -147,9 +175,10 @@ sequenceDiagram
|
|
|
147
175
|
|
|
148
176
|
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
177
|
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. **
|
|
151
|
-
4. **
|
|
152
|
-
5. **
|
|
178
|
+
3. **Optimized `#undoable?` Query Execution:** Calling `#undoable?` executes a single optimized `EXISTS` query (`joins(:undo_log).exists?(item_type: self.class.name, item_id: id)`). This checks for the presence of both the audit item and the valid parent log without loading records into Ruby memory.
|
|
179
|
+
4. **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.
|
|
180
|
+
5. **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.
|
|
181
|
+
6. **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
182
|
|
|
154
183
|
---
|
|
155
184
|
|
|
@@ -177,8 +206,15 @@ sequenceDiagram
|
|
|
177
206
|
- `undoable_column`: Caches the name of the column (defaults to `:deleted_at`).
|
|
178
207
|
- `kept` scope: Returns records that are not soft-deleted (`where(column => nil)`).
|
|
179
208
|
- `soft_deleted` scope: Returns records that are soft-deleted (`where.not(column => nil)`).
|
|
209
|
+
- `expired` scope: Returns records soft-deleted before the global retention period threshold.
|
|
180
210
|
* **`soft_deleted?`**
|
|
181
211
|
- *Function*: Checks if the current record instance has been soft-deleted. Returns `true` if the configured deletion column is populated with a timestamp.
|
|
212
|
+
* **`undoable?`**
|
|
213
|
+
- *Function*: Checks if the soft-deleted record has a valid undo log entry available for restoration.
|
|
214
|
+
- *Steps*:
|
|
215
|
+
1. Returns `false` immediately if `soft_deleted?` is `false`.
|
|
216
|
+
2. Performs a lightweight SQL `EXISTS` query (`ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(item_type: self.class.name, item_id: id)`).
|
|
217
|
+
3. Returns `true` if both the log item and its parent `UndoLog` exist in the database.
|
|
182
218
|
* **`soft_delete!`**
|
|
183
219
|
- *Function*: Starts the cascade soft-deletion sequence for the record.
|
|
184
220
|
- *Steps*:
|
|
@@ -196,6 +232,7 @@ sequenceDiagram
|
|
|
196
232
|
3. Resolves the latest `UndoLogItem` that records the soft-deletion of this instance.
|
|
197
233
|
4. If a log item is found, it calls `restore!` on the parent `UndoLog` (which restores the entire deleted tree).
|
|
198
234
|
5. If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to `nil`.
|
|
235
|
+
6. Automatically reloads the instance attributes in-memory (using `reload`) to refresh the model state before returning `true`.
|
|
199
236
|
* **`ensure_undoable_column_exists!` (Private)**
|
|
200
237
|
- *Function*: Asserts that the configured soft-delete column exists in the database schema table. Raises `ActiveRecord::Undo::Error` if missing.
|
|
201
238
|
* **`find_latest_undo_log_item` (Private)**
|
|
@@ -259,3 +296,30 @@ sequenceDiagram
|
|
|
259
296
|
- *Function*: Confirms that the target soft-delete column exists in the class's table schema. Throws `ActiveRecord::Undo::Error` if missing.
|
|
260
297
|
* **`reset_soft_delete_column!(target, column_name)` (Private)**
|
|
261
298
|
- *Function*: Bypasses standard callbacks and validations to write a `nil` value to the soft-delete column directly in the database.
|
|
299
|
+
|
|
300
|
+
### 6.9 `lib/active_record/undo/configuration.rb` (Global Configuration)
|
|
301
|
+
|
|
302
|
+
* **`Configuration#initialize`**
|
|
303
|
+
- *Function*: Instantiates default configurations for the gem.
|
|
304
|
+
- *Details*: Sets default `@retention_period` to `30.days`.
|
|
305
|
+
|
|
306
|
+
### 6.10 `lib/active_record/undo/purger.rb` (Hard Purging Engine)
|
|
307
|
+
|
|
308
|
+
* **`Purger.purge_expired!(batch_size: 1000)`**
|
|
309
|
+
- *Function*: Cleans up all database records and logs that are past their retention limits.
|
|
310
|
+
- *Steps*:
|
|
311
|
+
1. Delegates to `purge_expired_logs!(batch_size)` to find and clean up expired `UndoLog` and `UndoLogItem` entries in batches using direct SQL `delete_all` execution.
|
|
312
|
+
2. Delegates to `purge_expired_records!(batch_size)` to eager-load the host Rails application models (preventing Zeitwerk lazy-load gaps), identify expired records via the `.expired` scope, and hard-delete them and their cascading dependent associations in batches.
|
|
313
|
+
- *Relational Integrity & Constraint Protection*: Bottom-up recursive cascading deletes are performed first for associations marked as `:destroy`, `:delete_all`, or `:soft_delete`. For `:nullify` configurations, foreign key values are updated to `nil`, falling back to cascading deletion if the column contains a database-level `NOT NULL` constraint.
|
|
314
|
+
|
|
315
|
+
### 6.11 `lib/active_record/undo/purge_job.rb` (Background Task Worker)
|
|
316
|
+
|
|
317
|
+
* **`PurgeJob#perform(batch_size: 1000)`**
|
|
318
|
+
- *Function*: Runs inside ActiveJob (when available) to execute purging asynchronously.
|
|
319
|
+
- *Details*: Invokes `ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)`.
|
|
320
|
+
|
|
321
|
+
### 6.12 `lib/active_record/undo/tasks/purge.rake` (Command Line Utility)
|
|
322
|
+
|
|
323
|
+
* **`active_record_undo:purge_expired`**
|
|
324
|
+
- *Function*: Exposes CLI Rake task for the purger engine.
|
|
325
|
+
- *Details*: Checks `ENV['BATCH_SIZE']` for custom batch sizes, falling back to 1000, and triggers `ActiveRecord::Undo::Purger.purge_expired!`.
|
data/README.md
CHANGED
|
@@ -12,11 +12,13 @@ Unlike conventional soft-deletion gems, `active_record-undo` automatically captu
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
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.
|
|
15
|
+
- ⏪ **Atomic Restores:** Reverses soft deletion for an entire object tree (`undo_log.restore!` or `record.restore!`) within a single database transaction.
|
|
16
|
+
- 🔍 **Restoration Verification:** Provides `#undoable?` to check if a record is soft-deleted and has a valid undo log entry available for restoration.
|
|
16
17
|
- ⚙️ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
|
|
17
18
|
- 📦 **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` models—no messy JSON payload parsing required.
|
|
18
19
|
- 🚂 **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 `#
|
|
20
|
+
- 🔍 **Default Scopes & Helpers:** Provides `.kept`, `.soft_deleted`, `#soft_deleted?`, and `#undoable?` query methods out of the box.
|
|
21
|
+
- 🧹 **Automatic Expiration & Purging:** Configurable global retention window with automated background purging (`PurgeJob` and Rake task) to clean up old soft-deleted records and logs.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
@@ -121,6 +123,24 @@ item.soft_deleted? # => true
|
|
|
121
123
|
item.archived_at # => 2026-08-08 22:20:16 UTC
|
|
122
124
|
```
|
|
123
125
|
|
|
126
|
+
### Checking Restoration Eligibility (`#undoable?`)
|
|
127
|
+
|
|
128
|
+
Use `#undoable?` to verify if a record is soft-deleted and has a corresponding `UndoLog` entry available in the database. This is ideal for conditionally rendering UI elements or validating controller actions:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
post = Post.unscoped.find(1)
|
|
132
|
+
|
|
133
|
+
if post.undoable?
|
|
134
|
+
# Render "Undo Deletion" button or execute restore
|
|
135
|
+
post.restore!
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`#undoable?` returns `false` if:
|
|
140
|
+
* The record is currently active (not soft deleted).
|
|
141
|
+
* The record was soft deleted manually via direct SQL/column updates without generating an undo log.
|
|
142
|
+
* The corresponding `UndoLog` record was purged or already restored.
|
|
143
|
+
|
|
124
144
|
### Inspect Deletion Logs
|
|
125
145
|
|
|
126
146
|
Inspect affected records through standard Rails associations on the returned `UndoLog`:
|
|
@@ -139,11 +159,11 @@ To restore a deleted object tree, you can invoke `restore!` either on the corres
|
|
|
139
159
|
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
160
|
|
|
141
161
|
```ruby
|
|
142
|
-
# Restores the post and all comments deleted in the same batch
|
|
162
|
+
# Restores the post and all comments deleted in the same batch (automatically reloads in-memory)
|
|
143
163
|
post.restore!
|
|
144
164
|
|
|
145
|
-
post.
|
|
146
|
-
post.comments.count
|
|
165
|
+
post.soft_deleted? # => false
|
|
166
|
+
post.comments.count # => 2
|
|
147
167
|
```
|
|
148
168
|
|
|
149
169
|
#### Option B: Restore from the `UndoLog`
|
|
@@ -159,7 +179,7 @@ post.comments.count # => 2
|
|
|
159
179
|
|
|
160
180
|
## Scopes & Querying
|
|
161
181
|
|
|
162
|
-
`ActiveRecord::Undo` provides scopes for filtering records based on the configured column:
|
|
182
|
+
`ActiveRecord::Undo` provides scopes and predicate helpers for filtering and checking records based on the configured column:
|
|
163
183
|
|
|
164
184
|
```ruby
|
|
165
185
|
# Fetch only active (non-deleted) records
|
|
@@ -168,18 +188,94 @@ Post.kept
|
|
|
168
188
|
# Fetch soft-deleted records
|
|
169
189
|
Post.soft_deleted
|
|
170
190
|
|
|
191
|
+
# Check if a record is soft-deleted
|
|
192
|
+
post.soft_deleted?
|
|
193
|
+
|
|
194
|
+
# Check if a record is soft-deleted AND can be restored via an undo log
|
|
195
|
+
post.undoable?
|
|
196
|
+
|
|
171
197
|
# Retrieve records including soft-deleted ones via unscoped
|
|
172
198
|
Post.unscoped.where(id: 1)
|
|
173
199
|
```
|
|
174
200
|
|
|
175
201
|
---
|
|
176
202
|
|
|
203
|
+
## Expiration, Retention & Auto-Purging
|
|
204
|
+
|
|
205
|
+
To prevent database bloat, `active_record-undo` supports automated retention periods, expiration checks, and background purging for both soft-deleted records and their associated `UndoLog` audit entries.
|
|
206
|
+
|
|
207
|
+
### 1. Global Configuration
|
|
208
|
+
|
|
209
|
+
You can configure a global retention period inside a Rails initializer:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# config/initializers/active_record_undo.rb
|
|
213
|
+
ActiveRecord::Undo.configure do |config|
|
|
214
|
+
# Default retention period for all soft-deleted records and undo logs (defaults to 30.days)
|
|
215
|
+
config.retention_period = 30.days
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
If `retention_period` is set to `nil`, records and logs will never expire.
|
|
220
|
+
|
|
221
|
+
### 2. Expiration Scopes & Helpers
|
|
222
|
+
|
|
223
|
+
The gem provides query scopes and instance predicate helpers:
|
|
224
|
+
|
|
225
|
+
- **Model `.expired` Scope**: Returns soft-deleted records older than the configured retention period.
|
|
226
|
+
```ruby
|
|
227
|
+
Post.expired # => ActiveRecord::Relation of posts soft-deleted > 30 days ago
|
|
228
|
+
```
|
|
229
|
+
- **Model `#expired?` Predicate**: Checks if a record is soft-deleted and past the retention period.
|
|
230
|
+
```ruby
|
|
231
|
+
post.expired? # => true/false
|
|
232
|
+
```
|
|
233
|
+
- **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
|
|
234
|
+
```ruby
|
|
235
|
+
ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 3. Background Purging
|
|
239
|
+
|
|
240
|
+
#### Purger Service
|
|
241
|
+
The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Purge all expired soft-deleted records and undo logs in batches
|
|
245
|
+
ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
> [!NOTE]
|
|
249
|
+
> **Relational Integrity & Constraint Protection**: To prevent database-level foreign key constraint failures (e.g. `FOREIGN KEY constraint failed`), `Purger` dynamically resolves dependent associations (such as `dependent: :destroy`, `dependent: :delete_all`, or `dependent: :soft_delete`) and recursively purges associated child records bottom-up before deleting the parent record. For associations configured with `dependent: :nullify`, it nullifies the foreign key (or cascades the deletion if the foreign key column is database-restricted to be `NOT NULL`).
|
|
250
|
+
|
|
251
|
+
#### ActiveJob Background Job
|
|
252
|
+
The gem provides an ActiveJob class that calls the Purger service:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# Enqueue the purge job to run in the background
|
|
256
|
+
ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
#### Engine Rake Task
|
|
260
|
+
You can run the purge task via Rake. This task is automatically loaded into host applications:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
# Run with the default batch size of 1000
|
|
264
|
+
$ rails active_record_undo:purge_expired
|
|
265
|
+
|
|
266
|
+
# Run with a custom batch size
|
|
267
|
+
$ BATCH_SIZE=500 rails active_record_undo:purge_expired
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
177
272
|
## How It Works
|
|
178
273
|
|
|
179
274
|
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
275
|
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
276
|
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. **
|
|
277
|
+
4. **Restoration Verification:** Calling `#undoable?` executes an efficient SQL query joining `undo_log_items` and `undo_logs` to ensure the entity is soft-deleted and its associated log entry exists before restoration.
|
|
278
|
+
5. **Atomic Operation:** All updates and log creations take place within an `ActiveRecord::Base.transaction`.
|
|
183
279
|
|
|
184
280
|
---
|
|
185
281
|
|
|
@@ -8,18 +8,44 @@ module ActiveRecord
|
|
|
8
8
|
module ModelExtension
|
|
9
9
|
extend ActiveSupport::Concern
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
module ClassMethods
|
|
12
12
|
# Accept a custom column parameter, defaulting to :deleted_at
|
|
13
13
|
def acts_as_undoable(column: :deleted_at)
|
|
14
14
|
include InstanceMethods
|
|
15
15
|
|
|
16
|
+
define_undo_attributes!(column)
|
|
17
|
+
define_undo_scopes!
|
|
18
|
+
|
|
19
|
+
ActiveRecord::Undo.registered_models << self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def define_undo_attributes!(column)
|
|
16
25
|
class_attribute :undoable_column
|
|
17
26
|
self.undoable_column = column.to_sym
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def define_undo_scopes!
|
|
30
|
+
define_basic_scopes!
|
|
31
|
+
define_expired_scope!
|
|
32
|
+
end
|
|
18
33
|
|
|
19
|
-
|
|
34
|
+
def define_basic_scopes!
|
|
20
35
|
scope :kept, -> { where(undoable_column => nil) }
|
|
21
36
|
scope :soft_deleted, -> { where.not(undoable_column => nil) }
|
|
22
37
|
end
|
|
38
|
+
|
|
39
|
+
def define_expired_scope!
|
|
40
|
+
scope :expired, lambda {
|
|
41
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
42
|
+
if period
|
|
43
|
+
where("#{table_name}.#{undoable_column} < ?", Time.current - period)
|
|
44
|
+
else
|
|
45
|
+
none
|
|
46
|
+
end
|
|
47
|
+
}
|
|
48
|
+
end
|
|
23
49
|
end
|
|
24
50
|
|
|
25
51
|
module InstanceMethods
|
|
@@ -27,6 +53,25 @@ module ActiveRecord
|
|
|
27
53
|
public_send(self.class.undoable_column).present?
|
|
28
54
|
end
|
|
29
55
|
|
|
56
|
+
def expired?
|
|
57
|
+
return false unless soft_deleted?
|
|
58
|
+
|
|
59
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
60
|
+
return false unless period
|
|
61
|
+
|
|
62
|
+
timestamp = public_send(self.class.undoable_column)
|
|
63
|
+
timestamp && timestamp < Time.current - period
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def undoable?
|
|
67
|
+
return false unless soft_deleted?
|
|
68
|
+
|
|
69
|
+
ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(
|
|
70
|
+
item_type: self.class.name,
|
|
71
|
+
item_id: id
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
30
75
|
def soft_delete!
|
|
31
76
|
ensure_undoable_column_exists!
|
|
32
77
|
return false if soft_deleted?
|
|
@@ -43,20 +88,16 @@ module ActiveRecord
|
|
|
43
88
|
undo_log
|
|
44
89
|
end
|
|
45
90
|
|
|
91
|
+
# rubocop:disable Naming/PredicateMethod
|
|
46
92
|
def restore!
|
|
47
93
|
ensure_undoable_column_exists!
|
|
48
94
|
return false unless soft_deleted?
|
|
49
95
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
96
|
+
restore_internally!(find_latest_undo_log_item)
|
|
97
|
+
reload
|
|
98
|
+
true
|
|
59
99
|
end
|
|
100
|
+
# rubocop:enable Naming/PredicateMethod
|
|
60
101
|
|
|
61
102
|
private
|
|
62
103
|
|
|
@@ -79,6 +120,15 @@ module ActiveRecord
|
|
|
79
120
|
def soft_delete_cascade_internal!(timestamp, undo_log)
|
|
80
121
|
CascadeHandler.new(self).soft_delete_with_cascade!(timestamp, undo_log)
|
|
81
122
|
end
|
|
123
|
+
|
|
124
|
+
def restore_internally!(log_item)
|
|
125
|
+
if log_item
|
|
126
|
+
log_item.undo_log.restore!
|
|
127
|
+
else
|
|
128
|
+
column_name = self.class.undoable_column
|
|
129
|
+
update_columns(column_name => nil, updated_at: Time.current)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
82
132
|
end
|
|
83
133
|
end
|
|
84
134
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'active_job'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# ActiveJob is not available
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
if defined?(ActiveJob::Base)
|
|
10
|
+
module ActiveRecord
|
|
11
|
+
module Undo
|
|
12
|
+
class PurgeJob < ActiveJob::Base
|
|
13
|
+
queue_as :default
|
|
14
|
+
|
|
15
|
+
def perform(batch_size: 1000)
|
|
16
|
+
ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class Purger
|
|
6
|
+
class << self
|
|
7
|
+
def purge_expired!(batch_size: 1000)
|
|
8
|
+
purge_expired_logs!(batch_size)
|
|
9
|
+
purge_expired_records!(batch_size)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def purge_expired_logs!(batch_size)
|
|
15
|
+
loop do
|
|
16
|
+
log_ids = UndoLog.expired.limit(batch_size).pluck(:id)
|
|
17
|
+
break if log_ids.empty?
|
|
18
|
+
|
|
19
|
+
UndoLogItem.where(undo_log_id: log_ids).delete_all
|
|
20
|
+
UndoLog.where(id: log_ids).delete_all
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def purge_expired_records!(batch_size)
|
|
25
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
26
|
+
return unless period
|
|
27
|
+
|
|
28
|
+
ActiveRecord::Undo.undoable_models.each do |model|
|
|
29
|
+
purge_model_expired_records!(model, batch_size)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def purge_model_expired_records!(model, batch_size)
|
|
34
|
+
primary_key = model.primary_key
|
|
35
|
+
loop do
|
|
36
|
+
expired_ids = model.expired.limit(batch_size).pluck(primary_key)
|
|
37
|
+
break if expired_ids.empty?
|
|
38
|
+
|
|
39
|
+
purge_records_with_cascade!(model, expired_ids, batch_size)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def purge_records_with_cascade!(model, record_ids, batch_size)
|
|
44
|
+
return if record_ids.empty?
|
|
45
|
+
|
|
46
|
+
nullify_dependent_associations!(model, record_ids)
|
|
47
|
+
purge_cascading_associations!(model, record_ids, batch_size)
|
|
48
|
+
delete_records!(model, record_ids)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def nullify_dependent_associations!(model, record_ids)
|
|
52
|
+
nullify_reflections(model).each do |reflection|
|
|
53
|
+
child_query_for(model, reflection, record_ids).update_all(reflection.foreign_key => nil)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def purge_cascading_associations!(model, record_ids, batch_size)
|
|
58
|
+
cascade_reflections(model).each do |reflection|
|
|
59
|
+
query = child_query_for(model, reflection, record_ids)
|
|
60
|
+
primary_key = reflection.klass.primary_key
|
|
61
|
+
|
|
62
|
+
query.pluck(primary_key).each_slice(batch_size) do |child_ids|
|
|
63
|
+
purge_records_with_cascade!(reflection.klass, child_ids, batch_size)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def child_query_for(model, reflection, record_ids)
|
|
69
|
+
query = reflection.klass.unscoped.where(reflection.foreign_key => record_ids)
|
|
70
|
+
query = query.where(reflection.type => model.name) if reflection.type
|
|
71
|
+
query
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def delete_records!(model, record_ids)
|
|
75
|
+
model.unscoped.where(model.primary_key => record_ids).delete_all
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def nullify_reflections(model)
|
|
79
|
+
model.reflections.values.select do |ref|
|
|
80
|
+
%i[has_many has_one].include?(ref.macro) &&
|
|
81
|
+
ref.options[:dependent] == :nullify &&
|
|
82
|
+
foreign_key_nullable?(ref)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cascade_reflections(model)
|
|
87
|
+
model.reflections.values.select do |ref|
|
|
88
|
+
next false unless %i[has_many has_one].include?(ref.macro)
|
|
89
|
+
|
|
90
|
+
dependent = ref.options[:dependent]
|
|
91
|
+
%i[destroy soft_delete delete_all].include?(dependent) ||
|
|
92
|
+
non_nullable_nullify?(ref)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def non_nullable_nullify?(ref)
|
|
97
|
+
ref.options[:dependent] == :nullify && !foreign_key_nullable?(ref)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def foreign_key_nullable?(ref)
|
|
101
|
+
column = ref.klass.columns_hash[ref.foreign_key.to_s]
|
|
102
|
+
column.nil? || column.null
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :active_record_undo do
|
|
4
|
+
desc 'Purge expired soft-deleted records and undo logs'
|
|
5
|
+
task purge_expired: :environment do
|
|
6
|
+
batch_size = ENV['BATCH_SIZE'] ? ENV['BATCH_SIZE'].to_i : 1000
|
|
7
|
+
ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -8,6 +8,15 @@ module ActiveRecord
|
|
|
8
8
|
|
|
9
9
|
has_many :undo_log_items, class_name: 'ActiveRecord::Undo::UndoLogItem', dependent: :destroy
|
|
10
10
|
|
|
11
|
+
scope :expired, lambda {
|
|
12
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
13
|
+
if period
|
|
14
|
+
where('created_at < ?', Time.current - period)
|
|
15
|
+
else
|
|
16
|
+
none
|
|
17
|
+
end
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
# Restores all records associated with this deletion batch
|
|
12
21
|
def restore!
|
|
13
22
|
transaction do
|
data/lib/active_record/undo.rb
CHANGED
|
@@ -9,15 +9,52 @@ rescue LoadError => e
|
|
|
9
9
|
"Please add 'activerecord' to your Gemfile. (Original error: #{e.message})"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
require 'set'
|
|
12
13
|
require_relative 'undo/version'
|
|
13
14
|
require_relative 'undo/engine' if defined?(Rails::Engine)
|
|
15
|
+
require_relative 'undo/configuration'
|
|
14
16
|
require_relative 'undo/model_extension'
|
|
15
17
|
require_relative 'undo/undo_log'
|
|
16
18
|
require_relative 'undo/undo_log_item'
|
|
19
|
+
require_relative 'undo/purger'
|
|
20
|
+
require_relative 'undo/purge_job'
|
|
17
21
|
|
|
18
22
|
module ActiveRecord
|
|
19
23
|
module Undo
|
|
20
24
|
class Error < StandardError; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def config
|
|
28
|
+
@config ||= Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield(config)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def registered_models
|
|
36
|
+
@registered_models ||= Set.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def undoable_models
|
|
40
|
+
eager_load_rails!
|
|
41
|
+
models = registered_models.to_a
|
|
42
|
+
if defined?(ActiveRecord::Base)
|
|
43
|
+
models += ActiveRecord::Base.descendants.select { |m| m.respond_to?(:undoable_column) }
|
|
44
|
+
end
|
|
45
|
+
models.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def eager_load_rails!
|
|
51
|
+
return unless defined?(Rails) && Rails.application
|
|
52
|
+
|
|
53
|
+
Rails.application.eager_load!
|
|
54
|
+
rescue StandardError
|
|
55
|
+
# Safe fallback if Rails eager loading fails in test environments
|
|
56
|
+
end
|
|
57
|
+
end
|
|
21
58
|
end
|
|
22
59
|
end
|
|
23
60
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_record-undo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Saurabh Sharma
|
|
@@ -141,11 +141,16 @@ files:
|
|
|
141
141
|
- lib/active_record/undo/cascade_handler.rb
|
|
142
142
|
- lib/active_record/undo/cascade_handler/association_finder.rb
|
|
143
143
|
- lib/active_record/undo/cascade_handler/record_updater.rb
|
|
144
|
+
- lib/active_record/undo/configuration.rb
|
|
144
145
|
- lib/active_record/undo/engine.rb
|
|
145
146
|
- lib/active_record/undo/model_extension.rb
|
|
147
|
+
- lib/active_record/undo/purge_job.rb
|
|
148
|
+
- lib/active_record/undo/purger.rb
|
|
149
|
+
- lib/active_record/undo/tasks/purge.rake
|
|
146
150
|
- lib/active_record/undo/undo_log.rb
|
|
147
151
|
- lib/active_record/undo/undo_log_item.rb
|
|
148
152
|
- lib/active_record/undo/version.rb
|
|
153
|
+
- lib/tasks/active_record_undo_tasks.rake
|
|
149
154
|
- sig/active_record/undo.rbs
|
|
150
155
|
homepage: https://github.com/saurabh-activecode/active_record-undo
|
|
151
156
|
licenses:
|