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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30415ed66666364750a948cbbb06c6edffb88bff916bc5b7fc42d78ae027038d
4
- data.tar.gz: 83f4abe357af730e23bf89f85e93e009cd6f6207a8bb313bb5972b67c1b6488e
3
+ metadata.gz: aedb1b7356b0530c7316c1c94a4f2681cf4c103cb50986e24dfaa1f5d1eec553
4
+ data.tar.gz: 33f779d1c31b5c80126fa4838b8f080f512e25dca9dd372d88e3ff1d9a1b4eaa
5
5
  SHA512:
6
- metadata.gz: 4fc93992835bec03fff811b669ea856230b8498bb53e81b62be8e85e6d7d047d0f644f9568de5bf337d77bd4b2ed20f2a36b00155f8b8021c3a358e6bbb0b710
7
- data.tar.gz: a1afbb6efc8ce17b31b8f2a8b863e94c0c5d1f04ad4eb475d21a5599f525845002ced413793b8139d1241e936aaac2d3754b89ff11a3a4b2e139c42aa7756bec
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. **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.
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 `#soft_deleted?` query methods out of the box.
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.reload.soft_deleted? # => false
146
- post.comments.count # => 2
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. **Atomic Operation:** All updates and log creations take place within an `ActiveRecord::Base.transaction`.
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
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class Configuration
6
+ attr_accessor :retention_period
7
+
8
+ def initialize
9
+ @retention_period = 30.days
10
+ end
11
+ end
12
+ end
13
+ end
@@ -14,6 +14,11 @@ module ActiveRecord
14
14
  end
15
15
  end
16
16
  end
17
+
18
+ rake_tasks do
19
+ path = root.join('lib', 'tasks', 'active_record_undo_tasks.rake')
20
+ load path if File.exist?(path)
21
+ end
17
22
  end
18
23
  end
19
24
  end
@@ -8,18 +8,44 @@ module ActiveRecord
8
8
  module ModelExtension
9
9
  extend ActiveSupport::Concern
10
10
 
11
- class_methods do
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
- # Dynamic scopes using the configured column
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
- 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
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
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveRecord
5
5
  module Undo
6
- VERSION = '0.1.2'
6
+ VERSION = '0.1.4'
7
7
  end
8
8
  end
@@ -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
 
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ path = File.expand_path('../active_record/undo/tasks/purge.rake', __dir__)
4
+ load path if File.exist?(path)
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.2
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: