active_record-undo 0.1.4 → 0.2.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/DOCUMENTATION.md +226 -92
- data/README.md +402 -131
- data/app/controllers/active_record/undo/application_controller.rb +91 -0
- data/app/controllers/active_record/undo/logs_controller.rb +87 -0
- data/app/controllers/active_record/undo/restores_controller.rb +11 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb +16 -0
- data/lib/active_record/undo/configuration.rb +10 -1
- data/lib/active_record/undo/engine.rb +14 -1
- data/lib/active_record/undo/model_extension/attribution_helper.rb +54 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +39 -0
- data/lib/active_record/undo/model_extension.rb +61 -21
- 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/safe_redirect.rb +60 -0
- data/lib/active_record/undo/undo_log.rb +81 -2
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers.rb +101 -0
- data/lib/active_record/undo.rb +53 -0
- metadata +11 -1
|
@@ -0,0 +1,91 @@
|
|
|
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::SecurityError, with: :handle_security_error
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def resolve_whodunnit_user
|
|
17
|
+
if respond_to?(:current_user, true) && send(:current_user).present?
|
|
18
|
+
send(:current_user)
|
|
19
|
+
elsif ActiveRecord::Undo.config.current_user_method.respond_to?(:call)
|
|
20
|
+
ActiveRecord::Undo.config.current_user_method.call
|
|
21
|
+
else
|
|
22
|
+
ActiveRecord::Undo.whodunnit
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve_current_tenant
|
|
27
|
+
if respond_to?(:current_tenant, true) && send(:current_tenant).present?
|
|
28
|
+
send(:current_tenant)
|
|
29
|
+
elsif ActiveRecord::Undo.config.current_tenant_method.respond_to?(:call)
|
|
30
|
+
ActiveRecord::Undo.config.current_tenant_method.call
|
|
31
|
+
else
|
|
32
|
+
ActiveRecord::Undo.current_tenant
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def handle_security_error(error)
|
|
37
|
+
respond_to do |format|
|
|
38
|
+
format.html { handle_html_error(error.message, :forbidden) }
|
|
39
|
+
format.turbo_stream { render_turbo_stream_error(error.message, :forbidden) }
|
|
40
|
+
format.json { render json: { error: error.message }, status: :forbidden }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def handle_not_found
|
|
45
|
+
msg = 'Undo log not found or already restored.'
|
|
46
|
+
respond_to do |format|
|
|
47
|
+
format.html { handle_html_error(msg, :not_found) }
|
|
48
|
+
format.turbo_stream { render_turbo_stream_error(msg, :not_found) }
|
|
49
|
+
format.json { render json: { error: msg }, status: :not_found }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle_expired
|
|
54
|
+
msg = 'Undo action has expired.'
|
|
55
|
+
respond_to do |format|
|
|
56
|
+
format.html { handle_html_error(msg, :unprocessable_entity) }
|
|
57
|
+
format.turbo_stream { render_turbo_stream_error(msg, :unprocessable_entity) }
|
|
58
|
+
format.json { render json: { error: msg }, status: :unprocessable_entity }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_html_error(message, status)
|
|
63
|
+
if should_redirect_on_error?
|
|
64
|
+
redirect_to determine_redirect_path, alert: message, status: :see_other
|
|
65
|
+
else
|
|
66
|
+
render plain: message, status: status
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def should_redirect_on_error?
|
|
71
|
+
return true if ActiveRecord::Undo.config.error_handling == :redirect
|
|
72
|
+
return false if ActiveRecord::Undo.config.error_handling == :render
|
|
73
|
+
|
|
74
|
+
safe_redirect_path(params[:redirect_to]).present? || safe_redirect_path(request.referer).present?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_turbo_stream_error(message, status)
|
|
78
|
+
render inline: turbo_stream_template('alert', message),
|
|
79
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
80
|
+
status: status
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def turbo_stream_template(type, message)
|
|
84
|
+
escaped = ERB::Util.html_escape(message)
|
|
85
|
+
'<turbo-stream action="append" target="notifications"><template>' \
|
|
86
|
+
"<div class=\"undo-notification undo-#{type}\">#{escaped}</div>" \
|
|
87
|
+
'</template></turbo-stream>'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class LogsController < ApplicationController
|
|
6
|
+
before_action :set_undo_log
|
|
7
|
+
before_action :check_not_found
|
|
8
|
+
before_action :check_expired
|
|
9
|
+
|
|
10
|
+
def restore
|
|
11
|
+
return unless @undo_log
|
|
12
|
+
|
|
13
|
+
count = @undo_log.undo_log_items.count
|
|
14
|
+
execute_restore_action
|
|
15
|
+
respond_with_restored_log(count)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def set_undo_log
|
|
21
|
+
@undo_log = find_undo_log
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check_not_found
|
|
25
|
+
handle_not_found if @undo_log.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_expired
|
|
29
|
+
handle_expired if @undo_log&.expired?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_undo_log
|
|
33
|
+
if params[:token].present?
|
|
34
|
+
ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
|
|
35
|
+
elsif params[:id].present?
|
|
36
|
+
find_by_id_or_token(params[:id])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_by_id_or_token(identifier)
|
|
41
|
+
str = identifier.to_s
|
|
42
|
+
return nil if str.blank?
|
|
43
|
+
|
|
44
|
+
if str.match?(/\A\d+\z/) || uuid_identifier?(str)
|
|
45
|
+
ActiveRecord::Undo::UndoLog.find_by(id: identifier)
|
|
46
|
+
else
|
|
47
|
+
ActiveRecord::Undo::UndoLog.find_by_signed_token(str)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uuid_identifier?(str)
|
|
52
|
+
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)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def execute_restore_action
|
|
56
|
+
tenant = resolve_current_tenant
|
|
57
|
+
with_tenant_context(tenant) do
|
|
58
|
+
@undo_log.restore!(whodunnit: resolve_whodunnit_user)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def with_tenant_context(tenant)
|
|
63
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
64
|
+
ActiveRecord::Undo.current_tenant = tenant if tenant.present?
|
|
65
|
+
yield
|
|
66
|
+
ensure
|
|
67
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def respond_with_restored_log(count)
|
|
71
|
+
respond_to do |format|
|
|
72
|
+
format.html do
|
|
73
|
+
redirect_to determine_redirect_path, notice: 'Record successfully restored.', status: :see_other
|
|
74
|
+
end
|
|
75
|
+
format.turbo_stream { render_turbo_stream_success }
|
|
76
|
+
format.json { render json: { success: true, restored_items_count: count }, status: :ok }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_turbo_stream_success
|
|
81
|
+
render inline: turbo_stream_template('notice', 'Record successfully restored.'),
|
|
82
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
83
|
+
status: :ok
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -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,19 @@
|
|
|
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
|
+
:base_controller, :default_redirect_path, :token_secret_key,
|
|
8
|
+
:token_expires_in, :error_handling
|
|
7
9
|
|
|
8
10
|
def initialize
|
|
9
11
|
@retention_period = 30.days
|
|
12
|
+
@current_user_method = nil
|
|
13
|
+
@current_tenant_method = nil
|
|
14
|
+
@base_controller = '::ApplicationController'
|
|
15
|
+
@default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
|
|
16
|
+
@token_secret_key = nil
|
|
17
|
+
@token_expires_in = 24.hours
|
|
18
|
+
@error_handling = :auto
|
|
10
19
|
end
|
|
11
20
|
end
|
|
12
21
|
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,54 @@
|
|
|
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
|
+
|
|
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
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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.blank?
|
|
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
|
|
@@ -64,7 +69,7 @@ module ActiveRecord
|
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
def undoable?
|
|
67
|
-
return false unless soft_deleted?
|
|
72
|
+
return false unless persisted? && soft_deleted?
|
|
68
73
|
|
|
69
74
|
ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(
|
|
70
75
|
item_type: self.class.name,
|
|
@@ -72,28 +77,36 @@ module ActiveRecord
|
|
|
72
77
|
)
|
|
73
78
|
end
|
|
74
79
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
return false if soft_deleted?
|
|
80
|
+
def undo_log
|
|
81
|
+
return nil unless persisted? && soft_deleted?
|
|
78
82
|
|
|
79
|
-
undo_log
|
|
80
|
-
|
|
83
|
+
find_latest_undo_log_item&.undo_log
|
|
84
|
+
end
|
|
85
|
+
alias latest_undo_log undo_log
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
def signed_token(expires_in: ActiveRecord::Undo.config.token_expires_in, purpose: :restore)
|
|
88
|
+
undo_log&.signed_token(expires_in: expires_in, purpose: purpose)
|
|
89
|
+
end
|
|
90
|
+
alias to_signed_token signed_token
|
|
91
|
+
|
|
92
|
+
def soft_delete!(whodunnit: nil, tenant: nil)
|
|
93
|
+
ensure_undoable_column_exists!
|
|
94
|
+
return false if soft_deleted?
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
97
|
+
attrs = build_undo_log_attributes(whodunnit, tenant)
|
|
98
|
+
execute_soft_delete!(attrs)
|
|
89
99
|
end
|
|
90
100
|
|
|
91
101
|
# rubocop:disable Naming/PredicateMethod
|
|
92
|
-
def restore!
|
|
102
|
+
def restore!(whodunnit: nil)
|
|
93
103
|
ensure_undoable_column_exists!
|
|
94
104
|
return false unless soft_deleted?
|
|
95
105
|
|
|
96
|
-
|
|
106
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
107
|
+
log_item = find_latest_undo_log_item
|
|
108
|
+
log_item ? restore_from_log!(log_item.undo_log, whodunnit) : direct_restore!
|
|
109
|
+
|
|
97
110
|
reload
|
|
98
111
|
true
|
|
99
112
|
end
|
|
@@ -112,8 +125,9 @@ module ActiveRecord
|
|
|
112
125
|
|
|
113
126
|
def find_latest_undo_log_item
|
|
114
127
|
ActiveRecord::Undo::UndoLogItem
|
|
128
|
+
.joins(:undo_log)
|
|
115
129
|
.where(item: self)
|
|
116
|
-
.order(created_at: :desc)
|
|
130
|
+
.order(created_at: :desc, id: :desc)
|
|
117
131
|
.first
|
|
118
132
|
end
|
|
119
133
|
|
|
@@ -121,14 +135,40 @@ module ActiveRecord
|
|
|
121
135
|
CascadeHandler.new(self).soft_delete_with_cascade!(timestamp, undo_log)
|
|
122
136
|
end
|
|
123
137
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
def execute_soft_delete!(attrs)
|
|
139
|
+
transaction do
|
|
140
|
+
undo_log = ActiveRecord::Undo::UndoLog.create!(attrs)
|
|
141
|
+
soft_delete_cascade_internal!(Time.current, undo_log)
|
|
142
|
+
undo_log.save!
|
|
143
|
+
undo_log
|
|
130
144
|
end
|
|
131
145
|
end
|
|
146
|
+
|
|
147
|
+
def restore_from_log!(undo_log, whodunnit)
|
|
148
|
+
verify_tenant_match!(undo_log) if undo_log_tenant_present?(undo_log)
|
|
149
|
+
|
|
150
|
+
actor = resolve_whodunnit(whodunnit)
|
|
151
|
+
with_whodunnit_context(actor) do
|
|
152
|
+
undo_log.restore!
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def with_whodunnit_context(actor)
|
|
157
|
+
orig = ActiveRecord::Undo.whodunnit
|
|
158
|
+
ActiveRecord::Undo.whodunnit = actor
|
|
159
|
+
yield
|
|
160
|
+
ensure
|
|
161
|
+
ActiveRecord::Undo.whodunnit = orig
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def undo_log_tenant_present?(undo_log)
|
|
165
|
+
undo_log && (undo_log.tenant_id.present? || undo_log.tenant_type.present?)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def direct_restore!
|
|
169
|
+
column_name = self.class.undoable_column
|
|
170
|
+
update_columns(column_name => nil, updated_at: Time.current)
|
|
171
|
+
end
|
|
132
172
|
end
|
|
133
173
|
end
|
|
134
174
|
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
|