active_record-undo 0.1.4 → 0.1.5
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 +148 -88
- data/README.md +132 -14
- data/db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb +16 -0
- data/lib/active_record/undo/configuration.rb +3 -1
- data/lib/active_record/undo/model_extension/attribution_helper.rb +46 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +39 -0
- data/lib/active_record/undo/model_extension.rb +46 -19
- data/lib/active_record/undo/purger/reflection_helper.rb +38 -0
- data/lib/active_record/undo/purger.rb +50 -30
- data/lib/active_record/undo/undo_log.rb +21 -0
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo.rb +40 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16734d3b5f4910ac41564182b5cb70b05bf2013aeb488c1aac4475aefada4cc0
|
|
4
|
+
data.tar.gz: b302073f99a063d32c4ed4e811f3f5df651ac1c446c7d9ce082b03dedc44b1b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8fb0a5fa5ccd779460eb5211c2250fc40b7644f3d5c30739067b92e14caa1df582c547efab1c9aa36de12172fbf4235c7c469af90f22c6f30ab3e57a8be1690
|
|
7
|
+
data.tar.gz: 8a37a66af071b37b1e6fce2cd6dcc93c4365a25063d1c252a3f925e86f51f803561264a0182081abc0d6fecfcdb144f0e047233bf431e6d8a1e2827a4562e7cf
|
data/DOCUMENTATION.md
CHANGED
|
@@ -39,20 +39,23 @@ graph TD
|
|
|
39
39
|
|
|
40
40
|
### Component Summary
|
|
41
41
|
|
|
42
|
-
| Component
|
|
43
|
-
|
|
|
44
|
-
| **Main Hook**
|
|
45
|
-
| **Model Extension**
|
|
46
|
-
| **
|
|
47
|
-
| **
|
|
48
|
-
| **Cascade
|
|
49
|
-
| **
|
|
50
|
-
| **
|
|
51
|
-
| **
|
|
52
|
-
| **
|
|
53
|
-
| **
|
|
54
|
-
| **
|
|
55
|
-
| **
|
|
42
|
+
| Component | File Path | Class / Module | Core Responsibility |
|
|
43
|
+
| :----------------------------- | :------------------------------------------------------------- | :------------------- | :------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| **Main Hook & Context** | `lib/active_record/undo.rb` | `ActiveRecord::Undo` | Hooks into `ActiveSupport.on_load(:active_record)`, manages ambient thread context (`whodunnit`, `current_tenant`), and defines errors |
|
|
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!`) |
|
|
46
|
+
| **Model Attribution Helper** | `lib/active_record/undo/model_extension/attribution_helper.rb` | `AttributionHelper` | Resolves user attribution and tenant context from parameters, configured procs, or thread contexts |
|
|
47
|
+
| **Tenant Verification** | `lib/active_record/undo/model_extension/tenant_verification.rb`| `TenantVerification` | Validates initiating context against log tenant on `#restore!`, raising `SecurityError` on mismatch |
|
|
48
|
+
| **Cascade Engine** | `lib/active_record/undo/cascade_handler.rb` | `CascadeHandler` | Inspects ActiveRecord reflections (`reflections`) and executes DFS traversal |
|
|
49
|
+
| **Cascade Association Finder** | `lib/active_record/undo/cascade_handler/association_finder.rb` | `AssociationFinder` | Resolves which records should cascade based on dependency configuration |
|
|
50
|
+
| **Cascade Record Updater** | `lib/active_record/undo/cascade_handler/record_updater.rb` | `RecordUpdater` | Updates the database timestamps directly bypassing callbacks |
|
|
51
|
+
| **Audit Log Parent** | `lib/active_record/undo/undo_log.rb` | `UndoLog` | Represents the top-level deletion event and manages atomic batch restoration, tenancy, and user attribution |
|
|
52
|
+
| **Audit Log Child** | `lib/active_record/undo/undo_log_item.rb` | `UndoLogItem` | Maps polymorphic targets (`item_type`, `item_id`) to original deleted entities |
|
|
53
|
+
| **Engine Link** | `lib/active_record/undo/engine.rb` | `Engine` | Appends `db/migrate/` directly to host app migration paths |
|
|
54
|
+
| **Configuration** | `lib/active_record/undo/configuration.rb` | `Configuration` | Houses retention_period, current_user_method, and current_tenant_method settings |
|
|
55
|
+
| **Purger Service** | `lib/active_record/undo/purger.rb` | `Purger` | Deletes expired UndoLog/Items and soft-deleted model records in batches, scoped to tenant context when present |
|
|
56
|
+
| **Purger Reflection Helper** | `lib/active_record/undo/purger/reflection_helper.rb` | `ReflectionHelper` | Determines cascade and nullify reflection behavior for purge routines |
|
|
57
|
+
| **Purge Job** | `lib/active_record/undo/purge_job.rb` | `PurgeJob` | ActiveJob background runner invoking Purger |
|
|
58
|
+
| **Rake Task** | `lib/active_record/undo/tasks/purge.rake` | Rake Task | Exposes `active_record_undo:purge_expired` command |
|
|
56
59
|
|
|
57
60
|
---
|
|
58
61
|
|
|
@@ -62,9 +65,15 @@ graph TD
|
|
|
62
65
|
erDiagram
|
|
63
66
|
UNDO_LOGS ||--|{ UNDO_LOG_ITEMS : "has_many"
|
|
64
67
|
UNDO_LOG_ITEMS }|--|| TARGET_MODEL : "belongs_to (polymorphic)"
|
|
68
|
+
UNDO_LOGS }o--o| WHODUNNIT_MODEL : "belongs_to (polymorphic)"
|
|
69
|
+
UNDO_LOGS }o--o| TENANT_MODEL : "belongs_to (polymorphic)"
|
|
65
70
|
|
|
66
71
|
UNDO_LOGS {
|
|
67
72
|
bigint id PK
|
|
73
|
+
string whodunnit_type "optional"
|
|
74
|
+
bigint whodunnit_id "optional"
|
|
75
|
+
string tenant_type "optional"
|
|
76
|
+
bigint tenant_id "optional"
|
|
68
77
|
datetime created_at
|
|
69
78
|
datetime updated_at
|
|
70
79
|
}
|
|
@@ -179,147 +188,198 @@ sequenceDiagram
|
|
|
179
188
|
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
189
|
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
190
|
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.
|
|
191
|
+
7. **Tenant Matching Verification:** During `#restore!`, if `UndoLog` is associated with a tenant, the current tenant context is verified against the log's tenant before initiating restoration, raising `ActiveRecord::Undo::SecurityError` on mismatch to prevent cross-tenant data restores.
|
|
192
|
+
8. **User Attribution Transparency:** `#soft_delete!` and `#restore!` support transparent user tracking via explicit keyword arguments or ambient context resolution (`Current.user` / Thread context).
|
|
193
|
+
9. **Configured Context Enforcement:** When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations verify that the evaluated context is not `nil`. If any configured method evaluates to `nil`, `soft_delete!` and `restore!` immediately raise `ActiveRecord::Undo::SecurityError` to prevent unauthenticated or tenantless mutations.
|
|
182
194
|
|
|
183
195
|
---
|
|
184
196
|
|
|
185
197
|
## 6. Method-by-Method Implementation Reference
|
|
186
198
|
|
|
187
|
-
### 6.1 `lib/active_record/undo.rb` (Entrypoint)
|
|
199
|
+
### 6.1 `lib/active_record/undo.rb` (Entrypoint & Context)
|
|
188
200
|
|
|
189
201
|
* **`require 'active_record'` Loader Check**
|
|
190
|
-
|
|
202
|
+
* *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`.
|
|
191
203
|
* **Loader Hook Block**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
* *Function*: Detects if `ActiveSupport` is loaded:
|
|
205
|
+
* If present, registers `ActiveSupport.on_load(:active_record)` to inject the extension module when ActiveRecord boots.
|
|
206
|
+
* If absent (e.g. running in simple Ruby scripts), directly includes `ModelExtension` into `ActiveRecord::Base` as a fallback.
|
|
207
|
+
* **`whodunnit` / `whodunnit=`**
|
|
208
|
+
* *Function*: Thread/Fiber-safe accessor for the active actor performing deletions or restorations.
|
|
209
|
+
* **`current_tenant` / `current_tenant=`**
|
|
210
|
+
* *Function*: Thread/Fiber-safe accessor for the active tenant context.
|
|
211
|
+
* **`verify_configured_context!`**
|
|
212
|
+
* *Function*: Enforces that configured `current_user_method` and `current_tenant_method` do not return `nil`. Raises `ActiveRecord::Undo::SecurityError` if any configured method evaluates to `nil`.
|
|
213
|
+
* **`SecurityError` Exception Class**
|
|
214
|
+
* *Function*: Custom error inheriting from `ActiveRecord::Undo::Error`, raised when a cross-tenant restoration attempt, missing tenant context, or configured context violation occurs.
|
|
195
215
|
|
|
196
216
|
### 6.2 `lib/active_record/undo/engine.rb` (Rails Integration)
|
|
197
217
|
|
|
198
218
|
* **`initializer 'active_record_undo.migrations'`**
|
|
199
|
-
|
|
219
|
+
* *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.
|
|
200
220
|
|
|
201
221
|
### 6.3 `lib/active_record/undo/model_extension.rb` (Model Extension Module)
|
|
202
222
|
|
|
203
223
|
* **`acts_as_undoable(column: :deleted_at)`**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
224
|
+
* *Function*: Class-level DSL macro injected into models to enable soft-deletion.
|
|
225
|
+
* *Details*: Defines class-level configurations:
|
|
226
|
+
* `undoable_column`: Caches the name of the column (defaults to `:deleted_at`).
|
|
227
|
+
* `kept` scope: Returns records that are not soft-deleted (`where(column => nil)`).
|
|
228
|
+
* `soft_deleted` scope: Returns records that are soft-deleted (`where.not(column => nil)`).
|
|
229
|
+
* `expired` scope: Returns records soft-deleted before the global retention period threshold.
|
|
210
230
|
* **`soft_deleted?`**
|
|
211
|
-
|
|
231
|
+
* *Function*: Checks if the current record instance has been soft-deleted. Returns `true` if the configured deletion column is populated with a timestamp.
|
|
212
232
|
* **`undoable?`**
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
* *Function*: Checks if the soft-deleted record has a valid undo log entry available for restoration.
|
|
234
|
+
* *Steps*:
|
|
215
235
|
1. Returns `false` immediately if `soft_deleted?` is `false`.
|
|
216
236
|
2. Performs a lightweight SQL `EXISTS` query (`ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(item_type: self.class.name, item_id: id)`).
|
|
217
237
|
3. Returns `true` if both the log item and its parent `UndoLog` exist in the database.
|
|
218
|
-
* **`soft_delete
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
* **`soft_delete!(whodunnit: nil, tenant: nil)`**
|
|
239
|
+
* *Function*: Starts the cascade soft-deletion sequence for the record.
|
|
240
|
+
* *Parameters*:
|
|
241
|
+
* `whodunnit`: User/Actor executing the delete (falls back to `current_user_method` or Thread/Fiber context).
|
|
242
|
+
* `tenant`: Tenant context (falls back to `current_tenant_method`, Thread/Fiber context, or record's tenant association).
|
|
243
|
+
* *Steps*:
|
|
221
244
|
1. Calls `ensure_undoable_column_exists!` to verify the database column is present.
|
|
222
245
|
2. Aborts and returns `false` if the record is already soft-deleted.
|
|
223
|
-
3.
|
|
224
|
-
4.
|
|
225
|
-
5.
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
246
|
+
3. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
|
|
247
|
+
4. Resolves user and tenant attributes via `AttributionHelper`.
|
|
248
|
+
5. Opens an ActiveRecord database transaction block, creates a parent `UndoLog`, and cascades deletion via `CascadeHandler`.
|
|
249
|
+
* **`restore!(whodunnit: nil)`**
|
|
250
|
+
* *Function*: Restores the record from its soft-deleted state.
|
|
251
|
+
* *Parameters*:
|
|
252
|
+
* `whodunnit`: User/Actor executing the restore.
|
|
253
|
+
* *Steps*:
|
|
230
254
|
1. Verifies column presence via `ensure_undoable_column_exists!`.
|
|
231
255
|
2. Aborts and returns `false` if the record is not soft-deleted.
|
|
232
|
-
3.
|
|
233
|
-
4.
|
|
234
|
-
5. If
|
|
235
|
-
6.
|
|
256
|
+
3. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
|
|
257
|
+
4. Resolves the latest `UndoLogItem` that records the soft-deletion of this instance.
|
|
258
|
+
5. If a log item is found, verifies that the current tenant context matches the log's tenant via `TenantVerification` (raises `ActiveRecord::Undo::SecurityError` if mismatched).
|
|
259
|
+
6. Attributes the restoration transaction context to `whodunnit`.
|
|
260
|
+
7. Calls `restore!` on the parent `UndoLog` (which restores the entire deleted tree).
|
|
261
|
+
8. If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to `nil`.
|
|
262
|
+
9. Automatically reloads the instance attributes in-memory (using `reload`) to refresh the model state before returning `true`.
|
|
236
263
|
* **`ensure_undoable_column_exists!` (Private)**
|
|
237
|
-
|
|
264
|
+
* *Function*: Asserts that the configured soft-delete column exists in the database schema table. Raises `ActiveRecord::Undo::Error` if missing.
|
|
238
265
|
* **`find_latest_undo_log_item` (Private)**
|
|
239
|
-
|
|
266
|
+
* *Function*: Queries `UndoLogItem` records pointing to this record, ordering by `created_at DESC` to find the most recent deletion event.
|
|
240
267
|
* **`soft_delete_cascade_internal!(timestamp, undo_log)` (Private)**
|
|
241
|
-
|
|
268
|
+
* *Function*: Wraps instantiation and invocation of `CascadeHandler` to encapsulate cascade traversal.
|
|
269
|
+
|
|
270
|
+
### 6.4 `lib/active_record/undo/model_extension/attribution_helper.rb` (Attribution Helper)
|
|
271
|
+
|
|
272
|
+
* **`resolve_whodunnit(whodunnit)` (Private)**
|
|
273
|
+
* *Function*: Resolves the actor executing the action in priority order: explicit parameter -> `config.current_user_method.call` -> `ActiveRecord::Undo.whodunnit` -> `nil`.
|
|
274
|
+
* **`resolve_tenant(tenant)` (Private)**
|
|
275
|
+
* *Function*: Resolves the tenant context in priority order: explicit parameter -> `config.current_tenant_method.call` -> `ActiveRecord::Undo.current_tenant` -> model instance `tenant` association -> `nil`.
|
|
276
|
+
* **`build_undo_log_attributes(whodunnit, tenant)` (Private)**
|
|
277
|
+
* *Function*: Builds attributes hash supporting polymorphic model instances and scalar IDs for both actor and tenant.
|
|
278
|
+
|
|
279
|
+
### 6.5 `lib/active_record/undo/model_extension/tenant_verification.rb` (Tenant Verification)
|
|
242
280
|
|
|
243
|
-
|
|
281
|
+
* **`verify_tenant_match!(undo_log)` (Private)**
|
|
282
|
+
* *Function*: Asserts that the initiating context's tenant matches the log's tenant before restoration. Raises `ActiveRecord::Undo::SecurityError` if the context tenant is missing or mismatched.
|
|
283
|
+
* **`tenant_matches?(undo_log, ctx)` (Private)**
|
|
284
|
+
* *Function*: Compares `undo_log` tenant type/id against active context, supporting polymorphic model instances and raw scalar identifiers.
|
|
285
|
+
|
|
286
|
+
### 6.6 `lib/active_record/undo/cascade_handler.rb` (Cascade Execution)
|
|
244
287
|
|
|
245
288
|
* **`initialize(record)`**
|
|
246
|
-
|
|
289
|
+
* *Function*: Caches the record instance to be cascade deleted.
|
|
247
290
|
* **`soft_delete_with_cascade!(timestamp, undo_log)`**
|
|
248
|
-
|
|
249
|
-
|
|
291
|
+
* *Function*: Coordinates the cascade deletion of the current record.
|
|
292
|
+
* *Steps*:
|
|
250
293
|
1. Invokes `#cascade_to_associations!` to recurse into child tables.
|
|
251
294
|
2. Invokes `#update_record_timestamps!` to mark the current record as soft-deleted.
|
|
252
295
|
3. Appends an `UndoLogItem` pointing to this record to the `UndoLog` transaction.
|
|
253
296
|
* **`cascade_to_associations!(timestamp, undo_log)` (Private)**
|
|
254
|
-
|
|
297
|
+
* *Function*: Iterates over reflections retrieved by `AssociationFinder`, fetches their records, and calls `#cascade_to_record!` on each associated record.
|
|
255
298
|
* **`cascade_to_record!(associated, reflection, timestamp, undo_log)` (Private)**
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
299
|
+
* *Function*: Handles deletion of a single associated child record:
|
|
300
|
+
* Excludes it if it is already soft-deleted.
|
|
301
|
+
* If the child model is also configured with `acts_as_undoable`, calls its private `#soft_delete_cascade_internal!` recursively.
|
|
302
|
+
* If it is not undoable, but configured with `dependent: :destroy`, it invokes `#destroy!` to perform a hard-deletion.
|
|
260
303
|
|
|
261
|
-
### 6.
|
|
304
|
+
### 6.7 `lib/active_record/undo/cascade_handler/association_finder.rb` (Reflections Finder)
|
|
262
305
|
|
|
263
306
|
* **`associations_to_cascade` (Private)**
|
|
264
|
-
|
|
307
|
+
* *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`.
|
|
265
308
|
* **`associated_records_for(reflection)` (Private)**
|
|
266
|
-
|
|
309
|
+
* *Function*: Fetches associated target records. Normalizes single relations and collection associations (like `has_many`) into a flat array structure.
|
|
267
310
|
|
|
268
|
-
### 6.
|
|
311
|
+
### 6.8 `lib/active_record/undo/cascade_handler/record_updater.rb` (Timestamps Updater)
|
|
269
312
|
|
|
270
313
|
* **`update_record_timestamps!(timestamp)` (Private)**
|
|
271
|
-
|
|
314
|
+
* *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`.
|
|
272
315
|
|
|
273
|
-
### 6.
|
|
316
|
+
### 6.9 `lib/active_record/undo/undo_log.rb` (Batch Restoration)
|
|
274
317
|
|
|
275
318
|
* **`restore!`**
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
1.
|
|
279
|
-
2.
|
|
280
|
-
3.
|
|
281
|
-
4.
|
|
282
|
-
|
|
283
|
-
|
|
319
|
+
* *Function*: Triggers database restoration of the entire tree recorded under this log.
|
|
320
|
+
* *Steps*:
|
|
321
|
+
1. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
|
|
322
|
+
2. Opens a database transaction block.
|
|
323
|
+
3. Iterates over associated `undo_log_items` in *reverse order* (`reverse_each`), guaranteeing parent records are restored before child records.
|
|
324
|
+
4. Invokes `#restore_item!` on each item.
|
|
325
|
+
5. Automatically calls `#destroy!` on completion to purge the audit records (`UndoLog` and nested `UndoLogItem` rows) from the database.
|
|
326
|
+
* **`.for_whodunnit(user)`**
|
|
327
|
+
* *Function*: Scopes logs to those deleted by a specific user/actor. Supports both model instances and raw IDs.
|
|
328
|
+
* **`.for_tenant(tenant)`**
|
|
329
|
+
* *Function*: Scopes logs to those belonging to a specific tenant. Supports both model instances and raw IDs.
|
|
330
|
+
|
|
331
|
+
### 6.10 `lib/active_record/undo/undo_log_item.rb` (Item Restoration)
|
|
284
332
|
|
|
285
333
|
* **`restore_item!`**
|
|
286
|
-
|
|
287
|
-
|
|
334
|
+
* *Function*: Performs restoration of the individual record referenced by the audit log item.
|
|
335
|
+
* *Steps*:
|
|
288
336
|
1. Resolves model class via `#resolve_model_class`.
|
|
289
337
|
2. Resolves target record using `unscoped.find_by(id: item_id)` (unscoping ignores default scopes filtering soft-deleted records).
|
|
290
338
|
3. Ensures the soft-delete column exists on the model table.
|
|
291
339
|
4. Resets the soft-delete column to `nil` using `#reset_soft_delete_column!`.
|
|
292
340
|
* **`resolve_model_class` (Private)**
|
|
293
|
-
|
|
294
|
-
|
|
341
|
+
* *Function*: Constantizes the stored `item_type` string.
|
|
342
|
+
* *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.
|
|
295
343
|
* **`ensure_column_exists!(klass, column_name)` (Private)**
|
|
296
|
-
|
|
344
|
+
* *Function*: Confirms that the target soft-delete column exists in the class's table schema. Throws `ActiveRecord::Undo::Error` if missing.
|
|
297
345
|
* **`reset_soft_delete_column!(target, column_name)` (Private)**
|
|
298
|
-
|
|
346
|
+
* *Function*: Bypasses standard callbacks and validations to write a `nil` value to the soft-delete column directly in the database.
|
|
299
347
|
|
|
300
|
-
### 6.
|
|
348
|
+
### 6.11 `lib/active_record/undo/configuration.rb` (Global Configuration)
|
|
301
349
|
|
|
302
350
|
* **`Configuration#initialize`**
|
|
303
|
-
|
|
304
|
-
|
|
351
|
+
* *Function*: Instantiates default configurations for the gem.
|
|
352
|
+
* *Details*:
|
|
353
|
+
* `@retention_period`: Defaults to `30.days`.
|
|
354
|
+
* `@current_user_method`: Defaults to `nil`. Callable proc to resolve the current user/actor.
|
|
355
|
+
* `@current_tenant_method`: Defaults to `nil`. Callable proc to resolve the current tenant.
|
|
305
356
|
|
|
306
|
-
### 6.
|
|
357
|
+
### 6.12 `lib/active_record/undo/purger.rb` (Hard Purging Engine)
|
|
307
358
|
|
|
308
359
|
* **`Purger.purge_expired!(batch_size: 1000)`**
|
|
309
|
-
|
|
310
|
-
|
|
360
|
+
* *Function*: Cleans up all database records and logs that are past their retention limits.
|
|
361
|
+
* *Steps*:
|
|
311
362
|
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
363
|
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
|
-
|
|
364
|
+
* *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.
|
|
365
|
+
|
|
366
|
+
### 6.13 `lib/active_record/undo/purger/reflection_helper.rb` (Purger Reflection Helper)
|
|
367
|
+
|
|
368
|
+
* **`nullify_reflections(model)` (Private)**
|
|
369
|
+
* *Function*: Filters model reflections for `:has_many` or `:has_one` associations configured with `dependent: :nullify` where foreign key is nullable.
|
|
370
|
+
* **`cascade_reflections(model)` (Private)**
|
|
371
|
+
* *Function*: Filters model reflections for associations requiring cascade deletion (`:destroy`, `:delete_all`, `:soft_delete`, or non-nullable `:nullify`).
|
|
372
|
+
* **`foreign_key_nullable?(ref)` (Private)**
|
|
373
|
+
* *Function*: Checks table column schema to determine if the foreign key column allows null values.
|
|
314
374
|
|
|
315
|
-
### 6.
|
|
375
|
+
### 6.14 `lib/active_record/undo/purge_job.rb` (Background Task Worker)
|
|
316
376
|
|
|
317
377
|
* **`PurgeJob#perform(batch_size: 1000)`**
|
|
318
|
-
|
|
319
|
-
|
|
378
|
+
* *Function*: Runs inside ActiveJob (when available) to execute purging asynchronously.
|
|
379
|
+
* *Details*: Invokes `ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)`.
|
|
320
380
|
|
|
321
|
-
### 6.
|
|
381
|
+
### 6.15 `lib/active_record/undo/tasks/purge.rake` (Command Line Utility)
|
|
322
382
|
|
|
323
383
|
* **`active_record_undo:purge_expired`**
|
|
324
|
-
|
|
325
|
-
|
|
384
|
+
* *Function*: Exposes CLI Rake task for the purger engine.
|
|
385
|
+
* *Details*: Checks `ENV['BATCH_SIZE']` for custom batch sizes, falling back to 1000, and triggers `ActiveRecord::Undo::Purger.purge_expired!`.
|
data/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Unlike conventional soft-deletion gems, `active_record-undo` automatically captu
|
|
|
14
14
|
- 🔄 **Cascading Soft Deletes:** Soft deletes parent models along with dependent associations (`dependent: :destroy` / `:delete_all`).
|
|
15
15
|
- ⏪ **Atomic Restores:** Reverses soft deletion for an entire object tree (`undo_log.restore!` or `record.restore!`) within a single database transaction.
|
|
16
16
|
- 🔍 **Restoration Verification:** Provides `#undoable?` to check if a record is soft-deleted and has a valid undo log entry available for restoration.
|
|
17
|
+
- 👤 **User Attribution (`whodunnit`):** Tracks who initiated soft deletions and restores automatically via ambient context (e.g. `Current.user`) or explicit arguments.
|
|
18
|
+
- 🏢 **Multi-Tenant Isolation:** Scopes deletion audit logs to specific tenants/accounts and enforces strict tenant matching security on restoration.
|
|
17
19
|
- ⚙️ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
|
|
18
20
|
- 📦 **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` models—no messy JSON payload parsing required.
|
|
19
21
|
- 🚂 **Zero Generator Setup:** Built on top of `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
|
|
@@ -33,19 +35,19 @@ gem "active_record-undo"
|
|
|
33
35
|
Then execute:
|
|
34
36
|
|
|
35
37
|
```bash
|
|
36
|
-
|
|
38
|
+
bundle install
|
|
37
39
|
```
|
|
38
40
|
|
|
39
41
|
Run database migrations. The gem automatically appends its tables (`undo_logs` and `undo_log_items`) to your app's migration path:
|
|
40
42
|
|
|
41
43
|
```bash
|
|
42
|
-
|
|
44
|
+
rails db:migrate
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
*(Optional)* If you need to customize the migration, copy it to your host application's `db/migrate` folder:
|
|
46
48
|
|
|
47
49
|
```bash
|
|
48
|
-
|
|
50
|
+
rails active_record_undo:install:migrations
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
---
|
|
@@ -137,9 +139,10 @@ end
|
|
|
137
139
|
```
|
|
138
140
|
|
|
139
141
|
`#undoable?` returns `false` if:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
|
|
143
|
+
- The record is currently active (not soft deleted).
|
|
144
|
+
- The record was soft deleted manually via direct SQL/column updates without generating an undo log.
|
|
145
|
+
- The corresponding `UndoLog` record was purged or already restored.
|
|
143
146
|
|
|
144
147
|
### Inspect Deletion Logs
|
|
145
148
|
|
|
@@ -156,6 +159,7 @@ undo_log.undo_log_items.map(&:item)
|
|
|
156
159
|
To restore a deleted object tree, you can invoke `restore!` either on the corresponding `UndoLog` or directly on the model instance itself:
|
|
157
160
|
|
|
158
161
|
#### Option A: Restore from the model instance (Recommended)
|
|
162
|
+
|
|
159
163
|
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:
|
|
160
164
|
|
|
161
165
|
```ruby
|
|
@@ -167,6 +171,7 @@ post.comments.count # => 2
|
|
|
167
171
|
```
|
|
168
172
|
|
|
169
173
|
#### Option B: Restore from the `UndoLog`
|
|
174
|
+
|
|
170
175
|
```ruby
|
|
171
176
|
# Reverses soft deletes for the post and comments
|
|
172
177
|
undo_log.restore!
|
|
@@ -223,14 +228,19 @@ If `retention_period` is set to `nil`, records and logs will never expire.
|
|
|
223
228
|
The gem provides query scopes and instance predicate helpers:
|
|
224
229
|
|
|
225
230
|
- **Model `.expired` Scope**: Returns soft-deleted records older than the configured retention period.
|
|
231
|
+
|
|
226
232
|
```ruby
|
|
227
233
|
Post.expired # => ActiveRecord::Relation of posts soft-deleted > 30 days ago
|
|
228
234
|
```
|
|
235
|
+
|
|
229
236
|
- **Model `#expired?` Predicate**: Checks if a record is soft-deleted and past the retention period.
|
|
237
|
+
|
|
230
238
|
```ruby
|
|
231
239
|
post.expired? # => true/false
|
|
232
240
|
```
|
|
241
|
+
|
|
233
242
|
- **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
|
|
243
|
+
|
|
234
244
|
```ruby
|
|
235
245
|
ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
|
|
236
246
|
```
|
|
@@ -238,6 +248,7 @@ The gem provides query scopes and instance predicate helpers:
|
|
|
238
248
|
### 3. Background Purging
|
|
239
249
|
|
|
240
250
|
#### Purger Service
|
|
251
|
+
|
|
241
252
|
The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
|
|
242
253
|
|
|
243
254
|
```ruby
|
|
@@ -249,6 +260,7 @@ ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
|
|
|
249
260
|
> **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
261
|
|
|
251
262
|
#### ActiveJob Background Job
|
|
263
|
+
|
|
252
264
|
The gem provides an ActiveJob class that calls the Purger service:
|
|
253
265
|
|
|
254
266
|
```ruby
|
|
@@ -257,6 +269,7 @@ ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
|
|
|
257
269
|
```
|
|
258
270
|
|
|
259
271
|
#### Engine Rake Task
|
|
272
|
+
|
|
260
273
|
You can run the purge task via Rake. This task is automatically loaded into host applications:
|
|
261
274
|
|
|
262
275
|
```bash
|
|
@@ -269,6 +282,107 @@ $ BATCH_SIZE=500 rails active_record_undo:purge_expired
|
|
|
269
282
|
|
|
270
283
|
---
|
|
271
284
|
|
|
285
|
+
## Multi-Tenant Isolation & User Attribution
|
|
286
|
+
|
|
287
|
+
To support enterprise-grade Rails applications, `active_record-undo` provides native mechanisms for multi-tenant data isolation and user auditing.
|
|
288
|
+
|
|
289
|
+
### 1. Database Setup
|
|
290
|
+
|
|
291
|
+
Add a migration to introduce polymorphic tenant and user attribution columns to the `undo_logs` table:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
class AddTenantAndUserAttributionToUndoLogs < ActiveRecord::Migration[7.0]
|
|
295
|
+
def change
|
|
296
|
+
change_table :undo_logs, bulk: true do |t|
|
|
297
|
+
t.string :whodunnit_type, null: true
|
|
298
|
+
t.bigint :whodunnit_id, null: true
|
|
299
|
+
t.string :tenant_type, null: true
|
|
300
|
+
t.bigint :tenant_id, null: true
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: 'index_undo_logs_on_whodunnit'
|
|
304
|
+
add_index :undo_logs, [:tenant_type, :tenant_id], name: 'index_undo_logs_on_tenant'
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### 2. Global Configuration
|
|
310
|
+
|
|
311
|
+
Configure global procs (e.g. evaluating `CurrentAttributes`) in your initializer:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# config/initializers/active_record_undo.rb
|
|
315
|
+
ActiveRecord::Undo.configure do |config|
|
|
316
|
+
# Callable accessors to automatically resolve context
|
|
317
|
+
config.current_user_method = -> { Current.user }
|
|
318
|
+
config.current_tenant_method = -> { Current.account }
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 3. Usage & Thread Context
|
|
323
|
+
|
|
324
|
+
You can set thread/fiber context explicitly:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
ActiveRecord::Undo.whodunnit = current_user
|
|
328
|
+
ActiveRecord::Undo.current_tenant = current_account
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or pass values explicitly as parameters to model operations:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
# Capture user and tenant during deletion
|
|
335
|
+
post.soft_delete!(whodunnit: current_user, tenant: current_account)
|
|
336
|
+
|
|
337
|
+
# Track who performed the restoration
|
|
338
|
+
post.restore!(whodunnit: current_user)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### 4. Tenant Matching Security & Context Enforcement
|
|
342
|
+
|
|
343
|
+
#### Tenant Matching Security
|
|
344
|
+
|
|
345
|
+
When restoring a log that belongs to a tenant, the gem verifies that the initiating context's tenant matches the log's tenant. If there is a mismatch (or if no tenant is set in the context), the gem raises an `ActiveRecord::Undo::SecurityError`:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
ActiveRecord::Undo.current_tenant = wrong_account
|
|
349
|
+
post.restore! # => raises ActiveRecord::Undo::SecurityError: Tenant mismatch
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
#### Configured Context Enforcement
|
|
353
|
+
|
|
354
|
+
When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations enforce that the evaluated context is not `nil`:
|
|
355
|
+
|
|
356
|
+
- Calling `soft_delete!` or `restore!` when a configured method evaluates to `nil` immediately raises an `ActiveRecord::Undo::SecurityError`.
|
|
357
|
+
- If both user and tenant methods are configured, both must return valid non-nil objects for operations to proceed.
|
|
358
|
+
- If neither method is configured, operations proceed normally without requiring user or tenant context.
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# If current_user_method is configured (e.g. -> { Current.user }),
|
|
362
|
+
# but the request is unauthenticated (evaluates to nil):
|
|
363
|
+
post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
|
|
364
|
+
post.restore! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 5. Query Scopes & Purging
|
|
368
|
+
|
|
369
|
+
Query logs scoped to a specific user or tenant:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
ActiveRecord::Undo::UndoLog.for_whodunnit(user)
|
|
373
|
+
ActiveRecord::Undo::UndoLog.for_tenant(account)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
If `current_tenant` is set in the context when running the `Purger`, purging of both logs and model records will be strictly scoped to that tenant:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
# Scoped purge for account_1
|
|
380
|
+
ActiveRecord::Undo.current_tenant = account_1
|
|
381
|
+
ActiveRecord::Undo::Purger.purge_expired!
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
272
386
|
## How It Works
|
|
273
387
|
|
|
274
388
|
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`.
|
|
@@ -281,10 +395,14 @@ $ BATCH_SIZE=500 rails active_record_undo:purge_expired
|
|
|
281
395
|
|
|
282
396
|
## Error Handling
|
|
283
397
|
|
|
284
|
-
To ensure database integrity and provide clear debugging context, the gem raises
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
398
|
+
To ensure database integrity and provide clear debugging context, the gem raises errors in the following scenarios:
|
|
399
|
+
|
|
400
|
+
- **Missing Column at Runtime (`ActiveRecord::Undo::Error`):** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
|
|
401
|
+
- **Missing Model Class (`ActiveRecord::Undo::Error`):** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
|
|
402
|
+
- **Missing Column on Target Class (`ActiveRecord::Undo::Error`):** If a model class exists but no longer has the target soft-delete column during restore.
|
|
403
|
+
- **Cross-Tenant Restoration Attempt (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a record whose `UndoLog` belongs to a tenant different from the current context tenant.
|
|
404
|
+
- **Missing Tenant Context on Restoration (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a tenant-scoped record when no tenant is set in the context.
|
|
405
|
+
- **Configured Context Evaluated to Nil (`ActiveRecord::Undo::SecurityError`):** If `current_user_method` or `current_tenant_method` is configured but returns `nil` during `soft_delete!` or `restore!`.
|
|
288
406
|
|
|
289
407
|
---
|
|
290
408
|
|
|
@@ -293,21 +411,21 @@ To ensure database integrity and provide clear debugging context, the gem raises
|
|
|
293
411
|
After cloning the repository, install dependencies:
|
|
294
412
|
|
|
295
413
|
```bash
|
|
296
|
-
|
|
414
|
+
bundle install
|
|
297
415
|
```
|
|
298
416
|
|
|
299
417
|
Run test suite via RSpec:
|
|
300
418
|
|
|
301
419
|
```bash
|
|
302
|
-
|
|
420
|
+
bundle exec rspec
|
|
303
421
|
```
|
|
304
422
|
|
|
305
423
|
---
|
|
306
424
|
|
|
307
425
|
## Contributing
|
|
308
426
|
|
|
309
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo
|
|
427
|
+
Bug reports and pull requests are welcome on GitHub at <https://github.com/saurabh-activecode/active_record-undo>.
|
|
310
428
|
|
|
311
429
|
## License
|
|
312
430
|
|
|
313
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
431
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class AddTenantAndUserAttributionToUndoLogs < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
change_table :undo_logs, bulk: true do |t|
|
|
7
|
+
t.string :whodunnit_type, null: true
|
|
8
|
+
t.bigint :whodunnit_id, null: true
|
|
9
|
+
t.string :tenant_type, null: true
|
|
10
|
+
t.bigint :tenant_id, null: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :undo_logs, %i[whodunnit_type whodunnit_id], name: 'index_undo_logs_on_whodunnit'
|
|
14
|
+
add_index :undo_logs, %i[tenant_type tenant_id], name: 'index_undo_logs_on_tenant'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
module ActiveRecord
|
|
4
4
|
module Undo
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :retention_period
|
|
6
|
+
attr_accessor :retention_period, :current_user_method, :current_tenant_method
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@retention_period = 30.days
|
|
10
|
+
@current_user_method = nil
|
|
11
|
+
@current_tenant_method = nil
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
module ModelExtension
|
|
6
|
+
module AttributionHelper
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def resolve_whodunnit(whodunnit)
|
|
10
|
+
whodunnit || ActiveRecord::Undo.config.current_user_method&.call || ActiveRecord::Undo.whodunnit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve_tenant(tenant)
|
|
14
|
+
tenant || ActiveRecord::Undo.config.current_tenant_method&.call ||
|
|
15
|
+
ActiveRecord::Undo.current_tenant || (self.tenant if respond_to?(:tenant))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_undo_log_attributes(whodunnit, tenant)
|
|
19
|
+
attrs = {}
|
|
20
|
+
actor = resolve_whodunnit(whodunnit)
|
|
21
|
+
assign_whodunnit_attribute!(attrs, actor) if actor
|
|
22
|
+
|
|
23
|
+
ten = resolve_tenant(tenant)
|
|
24
|
+
assign_tenant_attribute!(attrs, ten) if ten
|
|
25
|
+
attrs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assign_whodunnit_attribute!(attrs, actor)
|
|
29
|
+
if actor.is_a?(ActiveRecord::Base)
|
|
30
|
+
attrs[:whodunnit] = actor
|
|
31
|
+
else
|
|
32
|
+
attrs[:whodunnit_id] = actor
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assign_tenant_attribute!(attrs, ten)
|
|
37
|
+
if ten.is_a?(ActiveRecord::Base)
|
|
38
|
+
attrs[:tenant] = ten
|
|
39
|
+
else
|
|
40
|
+
attrs[:tenant_id] = ten
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
module ModelExtension
|
|
6
|
+
module TenantVerification
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def verify_tenant_match!(undo_log)
|
|
10
|
+
ctx = current_tenant_context
|
|
11
|
+
raise_tenant_mismatch!(undo_log, 'nil') if ctx.nil?
|
|
12
|
+
|
|
13
|
+
return if tenant_matches?(undo_log, ctx)
|
|
14
|
+
|
|
15
|
+
ctx_info = ctx.is_a?(ActiveRecord::Base) ? "#{ctx.class.name}##{ctx.id}" : ctx.to_s
|
|
16
|
+
raise_tenant_mismatch!(undo_log, ctx_info)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tenant_matches?(undo_log, ctx)
|
|
20
|
+
if ctx.is_a?(ActiveRecord::Base)
|
|
21
|
+
undo_log.tenant_type == ctx.class.name && undo_log.tenant_id.to_s == ctx.id.to_s
|
|
22
|
+
else
|
|
23
|
+
undo_log.tenant_id.to_s == ctx.to_s
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def current_tenant_context
|
|
28
|
+
ActiveRecord::Undo.config.current_tenant_method&.call || ActiveRecord::Undo.current_tenant
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def raise_tenant_mismatch!(undo_log, ctx_info)
|
|
32
|
+
raise ActiveRecord::Undo::SecurityError,
|
|
33
|
+
"Tenant mismatch: log belongs to tenant #{undo_log.tenant_type}##{undo_log.tenant_id}, " \
|
|
34
|
+
"but current context tenant is #{ctx_info}."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative 'cascade_handler'
|
|
5
|
+
require_relative 'model_extension/attribution_helper'
|
|
6
|
+
require_relative 'model_extension/tenant_verification'
|
|
5
7
|
|
|
6
8
|
module ActiveRecord
|
|
7
9
|
module Undo
|
|
@@ -49,6 +51,9 @@ module ActiveRecord
|
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
module InstanceMethods
|
|
54
|
+
include AttributionHelper
|
|
55
|
+
include TenantVerification
|
|
56
|
+
|
|
52
57
|
def soft_deleted?
|
|
53
58
|
public_send(self.class.undoable_column).present?
|
|
54
59
|
end
|
|
@@ -72,28 +77,24 @@ module ActiveRecord
|
|
|
72
77
|
)
|
|
73
78
|
end
|
|
74
79
|
|
|
75
|
-
def soft_delete!
|
|
80
|
+
def soft_delete!(whodunnit: nil, tenant: nil)
|
|
76
81
|
ensure_undoable_column_exists!
|
|
77
82
|
return false if soft_deleted?
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
transaction do
|
|
83
|
-
undo_log = ActiveRecord::Undo::UndoLog.create!
|
|
84
|
-
soft_delete_cascade_internal!(timestamp, undo_log)
|
|
85
|
-
undo_log.save!
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
undo_log
|
|
84
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
85
|
+
attrs = build_undo_log_attributes(whodunnit, tenant)
|
|
86
|
+
execute_soft_delete!(attrs)
|
|
89
87
|
end
|
|
90
88
|
|
|
91
89
|
# rubocop:disable Naming/PredicateMethod
|
|
92
|
-
def restore!
|
|
90
|
+
def restore!(whodunnit: nil)
|
|
93
91
|
ensure_undoable_column_exists!
|
|
94
92
|
return false unless soft_deleted?
|
|
95
93
|
|
|
96
|
-
|
|
94
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
95
|
+
log_item = find_latest_undo_log_item
|
|
96
|
+
log_item ? restore_from_log!(log_item.undo_log, whodunnit) : direct_restore!
|
|
97
|
+
|
|
97
98
|
reload
|
|
98
99
|
true
|
|
99
100
|
end
|
|
@@ -121,14 +122,40 @@ module ActiveRecord
|
|
|
121
122
|
CascadeHandler.new(self).soft_delete_with_cascade!(timestamp, undo_log)
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
def execute_soft_delete!(attrs)
|
|
126
|
+
transaction do
|
|
127
|
+
undo_log = ActiveRecord::Undo::UndoLog.create!(attrs)
|
|
128
|
+
soft_delete_cascade_internal!(Time.current, undo_log)
|
|
129
|
+
undo_log.save!
|
|
130
|
+
undo_log
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def restore_from_log!(undo_log, whodunnit)
|
|
135
|
+
verify_tenant_match!(undo_log) if undo_log_tenant_present?(undo_log)
|
|
136
|
+
|
|
137
|
+
actor = resolve_whodunnit(whodunnit)
|
|
138
|
+
with_whodunnit_context(actor) do
|
|
139
|
+
undo_log.restore!
|
|
130
140
|
end
|
|
131
141
|
end
|
|
142
|
+
|
|
143
|
+
def with_whodunnit_context(actor)
|
|
144
|
+
orig = ActiveRecord::Undo.whodunnit
|
|
145
|
+
ActiveRecord::Undo.whodunnit = actor
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
ActiveRecord::Undo.whodunnit = orig
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def undo_log_tenant_present?(undo_log)
|
|
152
|
+
undo_log && (undo_log.tenant_id.present? || undo_log.tenant_type.present?)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def direct_restore!
|
|
156
|
+
column_name = self.class.undoable_column
|
|
157
|
+
update_columns(column_name => nil, updated_at: Time.current)
|
|
158
|
+
end
|
|
132
159
|
end
|
|
133
160
|
end
|
|
134
161
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class Purger
|
|
6
|
+
module ReflectionHelper
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def nullify_reflections(model)
|
|
10
|
+
model.reflections.values.select do |ref|
|
|
11
|
+
%i[has_many has_one].include?(ref.macro) &&
|
|
12
|
+
ref.options[:dependent] == :nullify &&
|
|
13
|
+
foreign_key_nullable?(ref)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cascade_reflections(model)
|
|
18
|
+
model.reflections.values.select do |ref|
|
|
19
|
+
next false unless %i[has_many has_one].include?(ref.macro)
|
|
20
|
+
|
|
21
|
+
dependent = ref.options[:dependent]
|
|
22
|
+
%i[destroy soft_delete delete_all].include?(dependent) ||
|
|
23
|
+
non_nullable_nullify?(ref)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def non_nullable_nullify?(ref)
|
|
28
|
+
ref.options[:dependent] == :nullify && !foreign_key_nullable?(ref)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def foreign_key_nullable?(ref)
|
|
32
|
+
column = ref.klass.columns_hash[ref.foreign_key.to_s]
|
|
33
|
+
column.nil? || column.null
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'purger/reflection_helper'
|
|
4
|
+
|
|
3
5
|
module ActiveRecord
|
|
4
6
|
module Undo
|
|
5
7
|
class Purger
|
|
8
|
+
extend ReflectionHelper
|
|
9
|
+
|
|
6
10
|
class << self
|
|
7
11
|
def purge_expired!(batch_size: 1000)
|
|
8
12
|
purge_expired_logs!(batch_size)
|
|
@@ -13,7 +17,7 @@ module ActiveRecord
|
|
|
13
17
|
|
|
14
18
|
def purge_expired_logs!(batch_size)
|
|
15
19
|
loop do
|
|
16
|
-
log_ids =
|
|
20
|
+
log_ids = current_tenant_log_scope.limit(batch_size).pluck(:id)
|
|
17
21
|
break if log_ids.empty?
|
|
18
22
|
|
|
19
23
|
UndoLogItem.where(undo_log_id: log_ids).delete_all
|
|
@@ -21,6 +25,22 @@ module ActiveRecord
|
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
|
|
28
|
+
def current_tenant_log_scope
|
|
29
|
+
scope = UndoLog.expired
|
|
30
|
+
ctx = current_tenant_context
|
|
31
|
+
return scope unless ctx
|
|
32
|
+
|
|
33
|
+
if ctx.is_a?(ActiveRecord::Base)
|
|
34
|
+
scope.where(tenant_type: ctx.class.name, tenant_id: ctx.id)
|
|
35
|
+
else
|
|
36
|
+
scope.where(tenant_id: ctx)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_tenant_context
|
|
41
|
+
ActiveRecord::Undo.config.current_tenant_method&.call || ActiveRecord::Undo.current_tenant
|
|
42
|
+
end
|
|
43
|
+
|
|
24
44
|
def purge_expired_records!(batch_size)
|
|
25
45
|
period = ActiveRecord::Undo.config.retention_period
|
|
26
46
|
return unless period
|
|
@@ -31,15 +51,42 @@ module ActiveRecord
|
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
def purge_model_expired_records!(model, batch_size)
|
|
34
|
-
|
|
54
|
+
pk = model.primary_key
|
|
35
55
|
loop do
|
|
36
|
-
expired_ids = model.
|
|
56
|
+
expired_ids = scoped_model_expired(model).limit(batch_size).pluck(pk)
|
|
37
57
|
break if expired_ids.empty?
|
|
38
58
|
|
|
39
59
|
purge_records_with_cascade!(model, expired_ids, batch_size)
|
|
40
60
|
end
|
|
41
61
|
end
|
|
42
62
|
|
|
63
|
+
def scoped_model_expired(model)
|
|
64
|
+
scope = model.expired
|
|
65
|
+
ctx = current_tenant_context
|
|
66
|
+
return scope unless ctx
|
|
67
|
+
|
|
68
|
+
apply_tenant_scope(scope, model, ctx)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply_tenant_scope(scope, model, ctx)
|
|
72
|
+
if model.column_names.include?('tenant_id')
|
|
73
|
+
apply_tenant_column_scope(scope, model, ctx)
|
|
74
|
+
elsif model.reflect_on_association(:tenant)
|
|
75
|
+
scope.where(tenant: ctx)
|
|
76
|
+
else
|
|
77
|
+
scope
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def apply_tenant_column_scope(scope, model, ctx)
|
|
82
|
+
val = ctx.is_a?(ActiveRecord::Base) ? ctx.id : ctx
|
|
83
|
+
scope = scope.where(tenant_id: val)
|
|
84
|
+
if model.column_names.include?('tenant_type') && ctx.is_a?(ActiveRecord::Base)
|
|
85
|
+
scope = scope.where(tenant_type: ctx.class.name)
|
|
86
|
+
end
|
|
87
|
+
scope
|
|
88
|
+
end
|
|
89
|
+
|
|
43
90
|
def purge_records_with_cascade!(model, record_ids, batch_size)
|
|
44
91
|
return if record_ids.empty?
|
|
45
92
|
|
|
@@ -74,33 +121,6 @@ module ActiveRecord
|
|
|
74
121
|
def delete_records!(model, record_ids)
|
|
75
122
|
model.unscoped.where(model.primary_key => record_ids).delete_all
|
|
76
123
|
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
124
|
end
|
|
105
125
|
end
|
|
106
126
|
end
|
|
@@ -8,6 +8,25 @@ module ActiveRecord
|
|
|
8
8
|
|
|
9
9
|
has_many :undo_log_items, class_name: 'ActiveRecord::Undo::UndoLogItem', dependent: :destroy
|
|
10
10
|
|
|
11
|
+
belongs_to :whodunnit, polymorphic: true, optional: true
|
|
12
|
+
belongs_to :tenant, polymorphic: true, optional: true
|
|
13
|
+
|
|
14
|
+
scope :for_whodunnit, lambda { |user|
|
|
15
|
+
if user.is_a?(ActiveRecord::Base)
|
|
16
|
+
where(whodunnit: user)
|
|
17
|
+
else
|
|
18
|
+
where(whodunnit_id: user)
|
|
19
|
+
end
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
scope :for_tenant, lambda { |tenant|
|
|
23
|
+
if tenant.is_a?(ActiveRecord::Base)
|
|
24
|
+
where(tenant: tenant)
|
|
25
|
+
else
|
|
26
|
+
where(tenant_id: tenant)
|
|
27
|
+
end
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
scope :expired, lambda {
|
|
12
31
|
period = ActiveRecord::Undo.config.retention_period
|
|
13
32
|
if period
|
|
@@ -19,6 +38,8 @@ module ActiveRecord
|
|
|
19
38
|
|
|
20
39
|
# Restores all records associated with this deletion batch
|
|
21
40
|
def restore!
|
|
41
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
42
|
+
|
|
22
43
|
transaction do
|
|
23
44
|
# Reverse order ensures child records are restored before or after parents as needed
|
|
24
45
|
undo_log_items.reverse_each(&:restore_item!)
|
data/lib/active_record/undo.rb
CHANGED
|
@@ -22,8 +22,25 @@ require_relative 'undo/purge_job'
|
|
|
22
22
|
module ActiveRecord
|
|
23
23
|
module Undo
|
|
24
24
|
class Error < StandardError; end
|
|
25
|
+
class SecurityError < Error; end
|
|
25
26
|
|
|
26
27
|
class << self
|
|
28
|
+
def whodunnit
|
|
29
|
+
Thread.current[:active_record_undo_whodunnit]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def whodunnit=(value)
|
|
33
|
+
Thread.current[:active_record_undo_whodunnit] = value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def current_tenant
|
|
37
|
+
Thread.current[:active_record_undo_current_tenant]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_tenant=(value)
|
|
41
|
+
Thread.current[:active_record_undo_current_tenant] = value
|
|
42
|
+
end
|
|
43
|
+
|
|
27
44
|
def config
|
|
28
45
|
@config ||= Configuration.new
|
|
29
46
|
end
|
|
@@ -45,6 +62,29 @@ module ActiveRecord
|
|
|
45
62
|
models.uniq
|
|
46
63
|
end
|
|
47
64
|
|
|
65
|
+
def verify_configured_context!
|
|
66
|
+
user_null = configured_method_nil?(config.current_user_method)
|
|
67
|
+
tenant_null = configured_method_nil?(config.current_tenant_method)
|
|
68
|
+
return unless user_null || tenant_null
|
|
69
|
+
|
|
70
|
+
raise_configured_context_error!(user_null, tenant_null)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def configured_method_nil?(method)
|
|
74
|
+
method.respond_to?(:call) && method.call.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def raise_configured_context_error!(user_null, tenant_null)
|
|
78
|
+
msg = if user_null && tenant_null
|
|
79
|
+
'Configured current_user_method and current_tenant_method both returned nil.'
|
|
80
|
+
elsif user_null
|
|
81
|
+
'Configured current_user_method returned nil.'
|
|
82
|
+
else
|
|
83
|
+
'Configured current_tenant_method returned nil.'
|
|
84
|
+
end
|
|
85
|
+
raise ActiveRecord::Undo::SecurityError, msg
|
|
86
|
+
end
|
|
87
|
+
|
|
48
88
|
private
|
|
49
89
|
|
|
50
90
|
def eager_load_rails!
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Saurabh Sharma
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- README.md
|
|
138
138
|
- Rakefile
|
|
139
139
|
- db/migrate/20260808000000_create_active_record_undo_tables.rb
|
|
140
|
+
- db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb
|
|
140
141
|
- lib/active_record/undo.rb
|
|
141
142
|
- lib/active_record/undo/cascade_handler.rb
|
|
142
143
|
- lib/active_record/undo/cascade_handler/association_finder.rb
|
|
@@ -144,8 +145,11 @@ files:
|
|
|
144
145
|
- lib/active_record/undo/configuration.rb
|
|
145
146
|
- lib/active_record/undo/engine.rb
|
|
146
147
|
- lib/active_record/undo/model_extension.rb
|
|
148
|
+
- lib/active_record/undo/model_extension/attribution_helper.rb
|
|
149
|
+
- lib/active_record/undo/model_extension/tenant_verification.rb
|
|
147
150
|
- lib/active_record/undo/purge_job.rb
|
|
148
151
|
- lib/active_record/undo/purger.rb
|
|
152
|
+
- lib/active_record/undo/purger/reflection_helper.rb
|
|
149
153
|
- lib/active_record/undo/tasks/purge.rake
|
|
150
154
|
- lib/active_record/undo/undo_log.rb
|
|
151
155
|
- lib/active_record/undo/undo_log_item.rb
|