-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Saurabh Sharma edited this page Aug 9, 2026
·
1 revision
ActiveRecord::Undo is a Rails Engine gem providing transactional, cascade-aware soft deletion and restoration capabilities for ActiveRecord models. It records state transitions into a dedicated polymorphic audit structure within single database transactions.
graph TD
SubGraphHostApp[Host Rails Application Models] -->|acts_as_undoable| ModelExt[ActiveRecord::Undo::ModelExtension]
subgraph GemCore[ActiveRecord::Undo Core Engine]
ModelExt -->|Calls soft_delete!| TxBoundary[ActiveRecord::Base.transaction]
subgraph TxBoundary
CreateLog[Create UndoLog Parent]
CreateLog --> Cascade[CascadeHandler Engine]
Cascade -->|Reflects Associations| AssociationLoop{Has Dependent Relations?}
AssociationLoop -->|Yes| RecursiveCall[Recurse soft_delete_cascade_internal!]
AssociationLoop -->|No / Completed| UpdateCol[update_columns timestamp]
UpdateCol --> AppendItem[Build UndoLogItem Record]
end
TxBoundary --> LogResult[Return UndoLog Instance]
end
subgraph DatabaseStorage[Persistence Layer]
AppendItem --> UndoLogTable[(undo_logs)]
AppendItem --> UndoItemTable[(undo_log_items)]
end
| Component | File Path | Class / Module | Core Responsibility |
|---|---|---|---|
| Main Hook | lib/active_record/undo.rb |
ActiveRecord::Undo |
Hooks into ActiveSupport.on_load(:active_record)
|
| Model Extension | lib/active_record/undo/model_extension.rb |
ModelExtension |
Injects DSL (acts_as_undoable), scopes (kept, soft_deleted), and methods (soft_delete!) |
| Cascade Engine | lib/active_record/undo/cascade_handler.rb |
CascadeHandler |
Inspects ActiveRecord reflections (reflections) and executes DFS traversal |
| Cascade Association Finder | lib/active_record/undo/cascade_handler/association_finder.rb |
AssociationFinder |
Resolves which records should cascade based on dependency configuration |
| Cascade Record Updater | lib/active_record/undo/cascade_handler/record_updater.rb |
RecordUpdater |
Updates the database timestamps directly bypassing callbacks |
| Audit Log Parent | lib/active_record/undo/undo_log.rb |
UndoLog |
Represents the top-level deletion event and manages atomic batch restoration |
| Audit Log Child | lib/active_record/undo/undo_log_item.rb |
UndoLogItem |
Maps polymorphic targets (item_type, item_id) to original deleted entities |
| Engine Link | lib/active_record/undo/engine.rb |
Engine |
Appends db/migrate/ directly to host app migration paths |
erDiagram
UNDO_LOGS ||--|{ UNDO_LOG_ITEMS : "has_many"
UNDO_LOG_ITEMS }|--|| TARGET_MODEL : "belongs_to (polymorphic)"
UNDO_LOGS {
bigint id PK
datetime created_at
datetime updated_at
}
UNDO_LOG_ITEMS {
bigint id PK
bigint undo_log_id FK
string item_type
bigint item_id
datetime created_at
datetime updated_at
}
TARGET_MODEL {
bigint id PK
datetime deleted_at "or custom column"
}
sequenceDiagram
autonumber
actor User
participant Model as Post Model
participant Ext as ModelExtension
participant Tx as DB Transaction
participant Log as UndoLog
participant Cascade as CascadeHandler
participant Child as Comment Model
User->>Model: post.soft_delete!
Model->>Ext: Check soft_deleted?
Ext-->>Model: false
Model->>Tx: Open ActiveRecord::Base.transaction
Tx->>Log: UndoLog.create!
Tx->>Cascade: CascadeHandler.new(post).soft_delete_with_cascade!
rect rgb(240, 240, 240)
note over Cascade, Child: Dynamic Association Reflection
Cascade->>Cascade: Inspect Post.reflections (:comments)
Cascade->>Child: Recurse soft_delete_cascade_internal!
Child->>Child: update_columns(deleted_at: timestamp)
Child->>Log: undo_log_items.build(item: comment_101)
end
Cascade->>Model: update_columns(deleted_at: timestamp)
Cascade->>Log: undo_log_items.build(item: post)
Log->>Tx: undo_log.save!
Tx-->>Model: Commit Transaction
Model-->>User: Returns UndoLog Instance
sequenceDiagram
autonumber
actor User
participant Log as UndoLog
participant Item as UndoLogItem
participant Model as Target Models
User->>Log: undo_log.restore!
Log->>Log: Open ActiveRecord::Base.transaction
rect rgb(240, 240, 240)
note over Log, Model: Reverse Order Processing (Bottom-Up)
Log->>Item: undo_log_items.reverse_each
Item->>Model: TargetClass.unscoped.find_by(id)
Item->>Model: target.update_columns(column_name => nil)
end
Log->>Log: destroy! (Deletes UndoLog & UndoLogItems)
Log-->>User: Restoration Complete
-
Depth-First Traversal Order: Cascading deletes traverse downward to child records before updating the parent node. Child item associations are appended to
undo_log_itemsfirst, and the parent record is appended last. -
Reverse Restoration Order:
#restore!callsundo_log_items.reverse_each. This ensures the parent node is restored first before restoring its dependent records, maintaining database relational integrity. -
Bypassing Callbacks: Soft-deletion updates use
update_columns. This executes a direct SQLUPDATEquery without firing standard ActiveRecord persistence callbacks (save,validate), preventing unintended side effects during soft deletes. -
Unscoped Model Resolution:
#restore_item!usesklass.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. -
Class Inheritance Security Check: When constantizing stored class strings, the gem validates that target models inherit from
ActiveRecord::Baseto prevent arbitrary non-model constant manipulation.
-
require 'active_record'Loader Check-
Function: Safely imports ActiveRecord. If ActiveRecord is not in the load path, it catches the
LoadErrorand throws a detailed error instructing the developer to addactiverecordto theirGemfile.
-
Function: Safely imports ActiveRecord. If ActiveRecord is not in the load path, it catches the
-
Loader Hook Block
-
Function: Detects if
ActiveSupportis loaded:- If present, registers
ActiveSupport.on_load(:active_record)to inject the extension module when ActiveRecord boots. - If absent (e.g. running in simple Ruby scripts), directly includes
ModelExtensionintoActiveRecord::Baseas a fallback.
- If present, registers
-
Function: Detects if
-
initializer 'active_record_undo.migrations'- 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.
-
acts_as_undoable(column: :deleted_at)- Function: Class-level DSL macro injected into models to enable soft-deletion.
-
Details: Defines class-level configurations:
-
undoable_column: Caches the name of the column (defaults to:deleted_at). -
keptscope: Returns records that are not soft-deleted (where(column => nil)). -
soft_deletedscope: Returns records that are soft-deleted (where.not(column => nil)).
-
-
soft_deleted?-
Function: Checks if the current record instance has been soft-deleted. Returns
trueif the configured deletion column is populated with a timestamp.
-
Function: Checks if the current record instance has been soft-deleted. Returns
-
soft_delete!- Function: Starts the cascade soft-deletion sequence for the record.
-
Steps:
- Calls
ensure_undoable_column_exists!to verify the database column is present. - Aborts and returns
falseif the record is already soft-deleted. - Opens an ActiveRecord database transaction block.
- Creates a new parent
UndoLogobject. - Recursively invokes cascading soft-deletes on associations and marks the record itself as soft-deleted via
CascadeHandler. - Saves the transaction and returns the constructed
UndoLog.
- Calls
-
restore!- Function: Restores the record from its soft-deleted state.
-
Steps:
- Verifies column presence via
ensure_undoable_column_exists!. - Aborts and returns
falseif the record is not soft-deleted. - Resolves the latest
UndoLogItemthat records the soft-deletion of this instance. - If a log item is found, it calls
restore!on the parentUndoLog(which restores the entire deleted tree). - If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to
nil.
- Verifies column presence via
-
ensure_undoable_column_exists!(Private)-
Function: Asserts that the configured soft-delete column exists in the database schema table. Raises
ActiveRecord::Undo::Errorif missing.
-
Function: Asserts that the configured soft-delete column exists in the database schema table. Raises
-
find_latest_undo_log_item(Private)-
Function: Queries
UndoLogItemrecords pointing to this record, ordering bycreated_at DESCto find the most recent deletion event.
-
Function: Queries
-
soft_delete_cascade_internal!(timestamp, undo_log)(Private)-
Function: Wraps instantiation and invocation of
CascadeHandlerto encapsulate cascade traversal.
-
Function: Wraps instantiation and invocation of
-
initialize(record)- Function: Caches the record instance to be cascade deleted.
-
soft_delete_with_cascade!(timestamp, undo_log)- Function: Coordinates the cascade deletion of the current record.
-
Steps:
- Invokes
#cascade_to_associations!to recurse into child tables. - Invokes
#update_record_timestamps!to mark the current record as soft-deleted. - Appends an
UndoLogItempointing to this record to theUndoLogtransaction.
- Invokes
-
cascade_to_associations!(timestamp, undo_log)(Private)-
Function: Iterates over reflections retrieved by
AssociationFinder, fetches their records, and calls#cascade_to_record!on each associated record.
-
Function: Iterates over reflections retrieved by
-
cascade_to_record!(associated, reflection, timestamp, undo_log)(Private)-
Function: Handles deletion of a single associated child record:
- Excludes it if it is already soft-deleted.
- If the child model is also configured with
acts_as_undoable, calls its private#soft_delete_cascade_internal!recursively. - If it is not undoable, but configured with
dependent: :destroy, it invokes#destroy!to perform a hard-deletion.
-
Function: Handles deletion of a single associated child record:
-
associations_to_cascade(Private)-
Function: Reflects on the model's association metadata and filters list of associations to select only those configured with
dependent: :destroy,dependent: :soft_delete, ordependent: :delete_all.
-
Function: Reflects on the model's association metadata and filters list of associations to select only those configured with
-
associated_records_for(reflection)(Private)-
Function: Fetches associated target records. Normalizes single relations and collection associations (like
has_many) into a flat array structure.
-
Function: Fetches associated target records. Normalizes single relations and collection associations (like
-
update_record_timestamps!(timestamp)(Private)-
Function: Bypasses ActiveRecord validations, callbacks, and dirty checking to directly write updates for the soft-delete column and
updated_attimestamps using database-levelupdate_columns.
-
Function: Bypasses ActiveRecord validations, callbacks, and dirty checking to directly write updates for the soft-delete column and
-
restore!- Function: Triggers database restoration of the entire tree recorded under this log.
-
Steps:
- Opens a database transaction block.
- Iterates over associated
undo_log_itemsin reverse order (reverse_each), guaranteeing parent records are restored before child records. - Invokes
#restore_item!on each item. - Automatically calls
#destroy!on completion to purge the audit records (UndoLogand nestedUndoLogItemrows) from the database.
-
restore_item!- Function: Performs restoration of the individual record referenced by the audit log item.
-
Steps:
- Resolves model class via
#resolve_model_class. - Resolves target record using
unscoped.find_by(id: item_id)(unscoping ignores default scopes filtering soft-deleted records). - Ensures the soft-delete column exists on the model table.
- Resets the soft-delete column to
nilusing#reset_soft_delete_column!.
- Resolves model class via
-
resolve_model_class(Private)-
Function: Constantizes the stored
item_typestring. -
Security: Asserts that the constant is a valid class that inherits from
ActiveRecord::Base. RaisesActiveRecord::Undo::Errorif constantization fails or targets non-model classes.
-
Function: Constantizes the stored
-
ensure_column_exists!(klass, column_name)(Private)-
Function: Confirms that the target soft-delete column exists in the class's table schema. Throws
ActiveRecord::Undo::Errorif missing.
-
Function: Confirms that the target soft-delete column exists in the class's table schema. Throws
-
reset_soft_delete_column!(target, column_name)(Private)-
Function: Bypasses standard callbacks and validations to write a
nilvalue to the soft-delete column directly in the database.
-
Function: Bypasses standard callbacks and validations to write a