active_record-undo 0.1.5 → 0.3.0
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/CHANGELOG.md +78 -2
- data/DOCUMENTATION.md +340 -133
- data/README.md +473 -190
- data/app/controllers/active_record/undo/application_controller.rb +100 -0
- data/app/controllers/active_record/undo/logs_controller/async_handler.rb +50 -0
- data/app/controllers/active_record/undo/logs_controller.rb +116 -0
- data/app/controllers/active_record/undo/restores_controller.rb +11 -0
- data/app/jobs/active_record/undo/restore_job.rb +72 -0
- data/app/jobs/active_record/undo/soft_delete_job.rb +75 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb +15 -0
- data/lib/active_record/undo/cascade_handler.rb +66 -0
- data/lib/active_record/undo/configuration.rb +9 -1
- data/lib/active_record/undo/engine.rb +14 -1
- data/lib/active_record/undo/model_extension/async_methods.rb +68 -0
- data/lib/active_record/undo/model_extension/attribution_helper.rb +8 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +1 -1
- data/lib/active_record/undo/model_extension.rb +22 -6
- data/lib/active_record/undo/safe_redirect.rb +60 -0
- data/lib/active_record/undo/status_predicates.rb +39 -0
- data/lib/active_record/undo/undo_log/token_verifier.rb +48 -0
- data/lib/active_record/undo/undo_log.rb +77 -2
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers/status_badge_helper.rb +84 -0
- data/lib/active_record/undo/view_helpers.rb +105 -0
- data/lib/active_record/undo.rb +37 -1
- data/sig/active_record/undo.rbs +50 -1
- metadata +29 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Undo
|
|
7
|
+
class ApplicationController < ActiveRecord::Undo.base_controller_class
|
|
8
|
+
include SafeRedirect
|
|
9
|
+
|
|
10
|
+
protect_from_forgery with: :exception, unless: -> { request.format.json? }
|
|
11
|
+
|
|
12
|
+
rescue_from ActiveRecord::Undo::Error, with: :handle_undo_error
|
|
13
|
+
rescue_from ActiveRecord::Undo::SecurityError, with: :handle_security_error
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def resolve_whodunnit_user
|
|
18
|
+
if respond_to?(:current_user, true) && send(:current_user).present?
|
|
19
|
+
send(:current_user)
|
|
20
|
+
elsif ActiveRecord::Undo.config.current_user_method.respond_to?(:call)
|
|
21
|
+
ActiveRecord::Undo.config.current_user_method.call
|
|
22
|
+
else
|
|
23
|
+
ActiveRecord::Undo.whodunnit
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve_current_tenant
|
|
28
|
+
if respond_to?(:current_tenant, true) && send(:current_tenant).present?
|
|
29
|
+
send(:current_tenant)
|
|
30
|
+
elsif ActiveRecord::Undo.config.current_tenant_method.respond_to?(:call)
|
|
31
|
+
ActiveRecord::Undo.config.current_tenant_method.call
|
|
32
|
+
else
|
|
33
|
+
ActiveRecord::Undo.current_tenant
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_security_error(error)
|
|
38
|
+
respond_to do |format|
|
|
39
|
+
format.html { handle_html_error(error.message, :forbidden) }
|
|
40
|
+
format.turbo_stream { render_turbo_stream_error(error.message, :forbidden) }
|
|
41
|
+
format.json { render json: { error: error.message }, status: :forbidden }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def handle_undo_error(error)
|
|
46
|
+
respond_to do |format|
|
|
47
|
+
format.html { handle_html_error(error.message, :unprocessable_entity) }
|
|
48
|
+
format.turbo_stream { render_turbo_stream_error(error.message, :unprocessable_entity) }
|
|
49
|
+
format.json { render json: { error: error.message }, status: :unprocessable_entity }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle_not_found
|
|
54
|
+
msg = 'Undo log not found or already restored.'
|
|
55
|
+
respond_to do |format|
|
|
56
|
+
format.html { handle_html_error(msg, :not_found) }
|
|
57
|
+
format.turbo_stream { render_turbo_stream_error(msg, :not_found) }
|
|
58
|
+
format.json { render json: { error: msg }, status: :not_found }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_expired
|
|
63
|
+
msg = 'Undo action has expired.'
|
|
64
|
+
respond_to do |format|
|
|
65
|
+
format.html { handle_html_error(msg, :unprocessable_entity) }
|
|
66
|
+
format.turbo_stream { render_turbo_stream_error(msg, :unprocessable_entity) }
|
|
67
|
+
format.json { render json: { error: msg }, status: :unprocessable_entity }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def handle_html_error(message, status)
|
|
72
|
+
if should_redirect_on_error?
|
|
73
|
+
redirect_to determine_redirect_path, alert: message, status: :see_other
|
|
74
|
+
else
|
|
75
|
+
render plain: message, status: status
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def should_redirect_on_error?
|
|
80
|
+
return true if ActiveRecord::Undo.config.error_handling == :redirect
|
|
81
|
+
return false if ActiveRecord::Undo.config.error_handling == :render
|
|
82
|
+
|
|
83
|
+
safe_redirect_path(params[:redirect_to]).present? || safe_redirect_path(request.referer).present?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_turbo_stream_error(message, status)
|
|
87
|
+
render inline: turbo_stream_template('alert', message),
|
|
88
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
89
|
+
status: status
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def turbo_stream_template(type, message)
|
|
93
|
+
escaped = ERB::Util.html_escape(message)
|
|
94
|
+
'<turbo-stream action="append" target="notifications"><template>' \
|
|
95
|
+
"<div class=\"undo-notification undo-#{type}\">#{escaped}</div>" \
|
|
96
|
+
'</template></turbo-stream>'
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class LogsController < ApplicationController
|
|
6
|
+
module AsyncHandler
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def async_request?
|
|
10
|
+
params[:async].to_s == 'true' || request.headers['X-Undo-Async'].to_s == 'true'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute_async_restore_action
|
|
14
|
+
tenant = resolve_current_tenant
|
|
15
|
+
with_tenant_context(tenant) do
|
|
16
|
+
@undo_log.restore_later!(whodunnit: resolve_whodunnit_user)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def respond_with_async_restore_scheduled
|
|
21
|
+
notice = 'Restoration has been scheduled and is processing.'
|
|
22
|
+
respond_to do |format|
|
|
23
|
+
format.html { redirect_to determine_redirect_path, notice: notice, status: :see_other }
|
|
24
|
+
format.turbo_stream { render_turbo_stream_notice(notice, :accepted) }
|
|
25
|
+
format.json { render json: { success: true, status: 'pending', log_id: @undo_log.id }, status: :accepted }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_turbo_stream_notice(notice, status)
|
|
30
|
+
render inline: turbo_stream_template('notice', notice),
|
|
31
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
32
|
+
status: status
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log_status_payload
|
|
36
|
+
{
|
|
37
|
+
id: @undo_log.id,
|
|
38
|
+
action: @undo_log.action,
|
|
39
|
+
status: @undo_log.status,
|
|
40
|
+
soft_delete_status: @undo_log.soft_delete_status,
|
|
41
|
+
restore_status: @undo_log.restore_status,
|
|
42
|
+
error_message: @undo_log.error_message,
|
|
43
|
+
completed: @undo_log.completed?,
|
|
44
|
+
failed: @undo_log.failed?
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'logs_controller/async_handler'
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Undo
|
|
7
|
+
class LogsController < ApplicationController
|
|
8
|
+
include AsyncHandler
|
|
9
|
+
|
|
10
|
+
before_action :set_undo_log
|
|
11
|
+
before_action :check_not_found
|
|
12
|
+
before_action :check_expired
|
|
13
|
+
before_action :check_tenant_access
|
|
14
|
+
|
|
15
|
+
def restore
|
|
16
|
+
return unless @undo_log
|
|
17
|
+
|
|
18
|
+
if async_request?
|
|
19
|
+
execute_async_restore_action
|
|
20
|
+
respond_with_async_restore_scheduled
|
|
21
|
+
else
|
|
22
|
+
count = @undo_log.undo_log_items.count
|
|
23
|
+
execute_restore_action
|
|
24
|
+
respond_with_restored_log(count)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status
|
|
29
|
+
return unless @undo_log
|
|
30
|
+
|
|
31
|
+
respond_to do |format|
|
|
32
|
+
format.json { render json: log_status_payload, status: :ok }
|
|
33
|
+
format.html { render plain: @undo_log.status, status: :ok }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def set_undo_log
|
|
40
|
+
@undo_log = find_undo_log
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_not_found
|
|
44
|
+
handle_not_found if @undo_log.nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_expired
|
|
48
|
+
handle_expired if @undo_log&.expired?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_tenant_access
|
|
52
|
+
tenant = resolve_current_tenant
|
|
53
|
+
with_tenant_context(tenant) do
|
|
54
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
55
|
+
return unless @undo_log && (@undo_log.tenant_id.present? || @undo_log.tenant_type.present?)
|
|
56
|
+
|
|
57
|
+
@undo_log.send(:verify_tenant_match!, @undo_log)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_undo_log
|
|
62
|
+
if params[:token].present?
|
|
63
|
+
ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
|
|
64
|
+
elsif params[:id].present?
|
|
65
|
+
find_by_id_or_token(params[:id])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_by_id_or_token(identifier)
|
|
70
|
+
str = identifier.to_s
|
|
71
|
+
return nil if str.blank?
|
|
72
|
+
|
|
73
|
+
if str.match?(/\A\d+\z/) || uuid_identifier?(str)
|
|
74
|
+
ActiveRecord::Undo::UndoLog.find_by(id: identifier)
|
|
75
|
+
else
|
|
76
|
+
ActiveRecord::Undo::UndoLog.find_by_signed_token(str)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def uuid_identifier?(str)
|
|
81
|
+
str.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute_restore_action
|
|
85
|
+
tenant = resolve_current_tenant
|
|
86
|
+
with_tenant_context(tenant) do
|
|
87
|
+
@undo_log.restore!(whodunnit: resolve_whodunnit_user)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def with_tenant_context(tenant)
|
|
92
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
93
|
+
ActiveRecord::Undo.current_tenant = tenant if tenant.present?
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def respond_with_restored_log(count)
|
|
100
|
+
respond_to do |format|
|
|
101
|
+
format.html do
|
|
102
|
+
redirect_to determine_redirect_path, notice: 'Record successfully restored.', status: :see_other
|
|
103
|
+
end
|
|
104
|
+
format.turbo_stream { render_turbo_stream_success }
|
|
105
|
+
format.json { render json: { success: true, restored_items_count: count }, status: :ok }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_turbo_stream_success
|
|
110
|
+
render inline: turbo_stream_template('notice', 'Record successfully restored.'),
|
|
111
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
112
|
+
status: :ok
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class RestoreJob < ActiveRecord::Undo.base_job_class
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(undo_log_id, whodunnit: nil)
|
|
9
|
+
undo_log = find_undo_log(undo_log_id)
|
|
10
|
+
return unless undo_log
|
|
11
|
+
|
|
12
|
+
acquired = lock_and_start_processing!(undo_log)
|
|
13
|
+
return unless acquired
|
|
14
|
+
|
|
15
|
+
execute_restore_batches(undo_log, whodunnit)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_failure(undo_log, e)
|
|
18
|
+
raise e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_undo_log(undo_log_id)
|
|
24
|
+
ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lock_and_start_processing!(undo_log)
|
|
28
|
+
undo_log.with_lock do
|
|
29
|
+
return false if undo_log.processing?
|
|
30
|
+
return false if undo_log.completed?
|
|
31
|
+
|
|
32
|
+
undo_log.update!(status: :processing)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute_restore_batches(undo_log, whodunnit)
|
|
38
|
+
actor = undo_log.send(:resolve_whodunnit, whodunnit)
|
|
39
|
+
undo_log.send(:with_whodunnit_context, actor) do
|
|
40
|
+
with_tenant_context(undo_log) do
|
|
41
|
+
restore_all_items_in_batches(undo_log)
|
|
42
|
+
cleanup_log!(undo_log)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_tenant_context(undo_log)
|
|
48
|
+
ten = undo_log.tenant || undo_log.tenant_id
|
|
49
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
50
|
+
ActiveRecord::Undo.current_tenant = ten if ten.present?
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restore_all_items_in_batches(undo_log)
|
|
57
|
+
undo_log.undo_log_items.find_each(batch_size: 1000, order: :desc, &:restore_item!)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cleanup_log!(undo_log)
|
|
61
|
+
undo_log.undo_log_items.in_batches(of: 1000).delete_all
|
|
62
|
+
undo_log.destroy!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_failure(undo_log, exception)
|
|
66
|
+
return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
|
|
67
|
+
|
|
68
|
+
undo_log.update!(status: :failed, error_message: exception.message)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class SoftDeleteJob < ActiveRecord::Undo.base_job_class
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(record_class_name, record_id, undo_log_id, whodunnit: nil, tenant: nil)
|
|
9
|
+
undo_log = find_undo_log(undo_log_id)
|
|
10
|
+
return unless undo_log
|
|
11
|
+
|
|
12
|
+
acquired = lock_and_start_processing!(undo_log)
|
|
13
|
+
return unless acquired
|
|
14
|
+
|
|
15
|
+
execute_soft_delete_cascade(record_class_name, record_id, undo_log, whodunnit, tenant)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_failure(undo_log, e)
|
|
18
|
+
raise e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_undo_log(undo_log_id)
|
|
24
|
+
ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lock_and_start_processing!(undo_log)
|
|
28
|
+
undo_log.with_lock do
|
|
29
|
+
return false if undo_log.processing?
|
|
30
|
+
return false if undo_log.completed?
|
|
31
|
+
|
|
32
|
+
undo_log.update!(status: :processing)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute_soft_delete_cascade(class_name, record_id, undo_log, whodunnit, tenant)
|
|
38
|
+
record = class_name.constantize.unscoped.find(record_id)
|
|
39
|
+
timestamp = record_timestamp(record, undo_log)
|
|
40
|
+
|
|
41
|
+
run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
|
|
42
|
+
undo_log.update!(status: :completed)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record_timestamp(record, undo_log)
|
|
46
|
+
col = record.class.respond_to?(:undoable_column) ? record.class.undoable_column : :deleted_at
|
|
47
|
+
record.public_send(col) || undo_log.created_at || Time.current
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
|
|
51
|
+
with_execution_context(record, whodunnit, tenant) do
|
|
52
|
+
CascadeHandler.new(record).soft_delete_associations_batched!(timestamp, undo_log)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_execution_context(record, whodunnit, tenant)
|
|
57
|
+
actor = whodunnit || record.send(:resolve_whodunnit, nil)
|
|
58
|
+
ten = tenant || record.send(:resolve_tenant, nil)
|
|
59
|
+
record.send(:with_whodunnit_context, actor) do
|
|
60
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
61
|
+
ActiveRecord::Undo.current_tenant = ten if ten.present?
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_failure(undo_log, exception)
|
|
69
|
+
return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
|
|
70
|
+
|
|
71
|
+
undo_log.update!(status: :failed, error_message: exception.message)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ActiveRecord::Undo::Engine.routes.draw do
|
|
4
|
+
resources :logs, only: [] do
|
|
5
|
+
member do
|
|
6
|
+
post :restore
|
|
7
|
+
get :status
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
post '/restore/:token', to: 'restores#create', as: :signed_restore
|
|
12
|
+
get '/status/:token', to: 'restores#status', as: :signed_status
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class AddStatusAndErrorMessageToUndoLogs < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
change_table :undo_logs, bulk: true do |t|
|
|
7
|
+
t.integer :status, default: 2, null: false unless column_exists?(:undo_logs, :status)
|
|
8
|
+
t.integer :action, default: 0, null: false unless column_exists?(:undo_logs, :action)
|
|
9
|
+
t.text :error_message unless column_exists?(:undo_logs, :error_message)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :undo_logs, :status unless index_exists?(:undo_logs, :status)
|
|
13
|
+
add_index :undo_logs, :action unless index_exists?(:undo_logs, :action)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -20,8 +20,74 @@ module ActiveRecord
|
|
|
20
20
|
undo_log.undo_log_items.build(item: @record)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def soft_delete_associations_batched!(timestamp, undo_log, batch_size: 1000)
|
|
24
|
+
associations_to_cascade.each do |reflection|
|
|
25
|
+
cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
private
|
|
24
30
|
|
|
31
|
+
def cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
|
|
32
|
+
target = @record.public_send(reflection.name)
|
|
33
|
+
return if target.nil?
|
|
34
|
+
|
|
35
|
+
if reflection.collection? || target.is_a?(ActiveRecord::Relation)
|
|
36
|
+
cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
37
|
+
else
|
|
38
|
+
cascade_record_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
43
|
+
each_collection_batch(target, batch_size) do |batch|
|
|
44
|
+
batch.each do |associated|
|
|
45
|
+
cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def each_collection_batch(target, batch_size, &)
|
|
51
|
+
if target.respond_to?(:in_batches)
|
|
52
|
+
target.in_batches(of: batch_size, &)
|
|
53
|
+
else
|
|
54
|
+
target.each_slice(batch_size, &)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
|
|
59
|
+
return if skip_cascade?(associated)
|
|
60
|
+
|
|
61
|
+
if undoable_record?(associated)
|
|
62
|
+
process_undoable_child!(associated, timestamp, undo_log, batch_size)
|
|
63
|
+
elsif reflection.options[:dependent] == :destroy
|
|
64
|
+
associated.destroy!
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def skip_cascade?(associated)
|
|
69
|
+
return true if associated.nil?
|
|
70
|
+
return true if associated.respond_to?(:soft_deleted?) && associated.soft_deleted?
|
|
71
|
+
|
|
72
|
+
if associated.respond_to?(:soft_deleted?) && associated.persisted?
|
|
73
|
+
associated.reload
|
|
74
|
+
return true if associated.soft_deleted?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def undoable_record?(associated)
|
|
81
|
+
associated.respond_to?(:acts_as_undoable) || associated.class.respond_to?(:undoable_column)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def process_undoable_child!(associated, timestamp, undo_log, batch_size)
|
|
85
|
+
self.class.new(associated).soft_delete_associations_batched!(timestamp, undo_log, batch_size: batch_size)
|
|
86
|
+
column_name = associated.class.respond_to?(:undoable_column) ? associated.class.undoable_column : :deleted_at
|
|
87
|
+
associated.update_columns(column_name => timestamp, updated_at: timestamp)
|
|
88
|
+
ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: associated)
|
|
89
|
+
end
|
|
90
|
+
|
|
25
91
|
def cascade_to_associations!(timestamp, undo_log)
|
|
26
92
|
associations_to_cascade.each do |reflection|
|
|
27
93
|
associated_records_for(reflection).each do |associated|
|
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
module ActiveRecord
|
|
4
4
|
module Undo
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :retention_period, :current_user_method, :current_tenant_method
|
|
6
|
+
attr_accessor :retention_period, :current_user_method, :current_tenant_method,
|
|
7
|
+
:base_controller, :base_job, :default_redirect_path, :token_secret_key,
|
|
8
|
+
:token_expires_in, :error_handling
|
|
7
9
|
|
|
8
10
|
def initialize
|
|
9
11
|
@retention_period = 30.days
|
|
10
12
|
@current_user_method = nil
|
|
11
13
|
@current_tenant_method = nil
|
|
14
|
+
@base_controller = '::ApplicationController'
|
|
15
|
+
@base_job = '::ActiveJob::Base'
|
|
16
|
+
@default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
|
|
17
|
+
@token_secret_key = nil
|
|
18
|
+
@token_expires_in = 24.hours
|
|
19
|
+
@error_handling = :auto
|
|
12
20
|
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
# lib/active_record/undo/engine.rb
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
3
|
+
require_relative 'view_helpers'
|
|
4
|
+
|
|
4
5
|
module ActiveRecord
|
|
5
6
|
module Undo
|
|
6
7
|
class Engine < ::Rails::Engine
|
|
@@ -15,6 +16,18 @@ module ActiveRecord
|
|
|
15
16
|
end
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
initializer 'active_record_undo.view_helpers' do
|
|
20
|
+
ActiveSupport.on_load(:action_view) do
|
|
21
|
+
include ActiveRecord::Undo::ViewHelpers
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer 'active_record_undo.turbo_stream_mime_type' do
|
|
26
|
+
if defined?(Mime::Type) && (!defined?(Mime[:turbo_stream]) || Mime[:turbo_stream].nil?)
|
|
27
|
+
Mime::Type.register 'text/vnd.turbo-stream.html', :turbo_stream
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
18
31
|
rake_tasks do
|
|
19
32
|
path = root.join('lib', 'tasks', 'active_record_undo_tasks.rake')
|
|
20
33
|
load path if File.exist?(path)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
module ModelExtension
|
|
6
|
+
module AsyncMethods
|
|
7
|
+
def soft_delete_status
|
|
8
|
+
return nil unless soft_deleted?
|
|
9
|
+
return 'completed' unless undo_log
|
|
10
|
+
|
|
11
|
+
undo_log.soft_delete_status
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def restore_status
|
|
15
|
+
return nil unless undo_log
|
|
16
|
+
|
|
17
|
+
undo_log.restore_status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def soft_delete_later!(whodunnit: nil, tenant: nil, queue: nil)
|
|
21
|
+
ensure_undoable_column_exists!
|
|
22
|
+
return false if soft_deleted?
|
|
23
|
+
|
|
24
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
25
|
+
attrs = build_undo_log_attributes(whodunnit, tenant)
|
|
26
|
+
undo_log = execute_initial_soft_delete_later!(attrs)
|
|
27
|
+
enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
|
|
28
|
+
undo_log
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def restore_later!(whodunnit: nil, queue: nil)
|
|
32
|
+
ensure_undoable_column_exists!
|
|
33
|
+
return false unless soft_deleted?
|
|
34
|
+
|
|
35
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
36
|
+
log = undo_log
|
|
37
|
+
raise ActiveRecord::Undo::Error, 'No undo log found to restore.' unless log
|
|
38
|
+
|
|
39
|
+
log.restore_later!(whodunnit: whodunnit, queue: queue)
|
|
40
|
+
log
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def execute_initial_soft_delete_later!(attrs)
|
|
46
|
+
transaction do
|
|
47
|
+
timestamp = Time.current
|
|
48
|
+
undo_log = ActiveRecord::Undo::UndoLog.create!(attrs.merge(action: :soft_delete, status: :pending))
|
|
49
|
+
update_soft_delete_timestamps!(timestamp)
|
|
50
|
+
ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: self)
|
|
51
|
+
undo_log
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def update_soft_delete_timestamps!(timestamp)
|
|
56
|
+
column_name = self.class.undoable_column
|
|
57
|
+
update_columns(column_name => timestamp, updated_at: timestamp)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
|
|
61
|
+
job_class = ActiveRecord::Undo::SoftDeleteJob
|
|
62
|
+
job = queue.present? ? job_class.set(queue: queue) : job_class
|
|
63
|
+
job.perform_later(self.class.name, id, undo_log.id, whodunnit: whodunnit, tenant: tenant)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -40,6 +40,14 @@ module ActiveRecord
|
|
|
40
40
|
attrs[:tenant_id] = ten
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
def with_whodunnit_context(actor)
|
|
45
|
+
orig = ActiveRecord::Undo.whodunnit
|
|
46
|
+
ActiveRecord::Undo.whodunnit = actor
|
|
47
|
+
yield
|
|
48
|
+
ensure
|
|
49
|
+
ActiveRecord::Undo.whodunnit = orig
|
|
50
|
+
end
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
end
|