active_record-undo 0.1.3 → 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 +162 -69
- data/README.md +205 -17
- data/db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb +16 -0
- data/lib/active_record/undo/configuration.rb +15 -0
- data/lib/active_record/undo/engine.rb +5 -0
- 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 +89 -21
- data/lib/active_record/undo/purge_job.rb +21 -0
- data/lib/active_record/undo/purger/reflection_helper.rb +38 -0
- data/lib/active_record/undo/purger.rb +127 -0
- data/lib/active_record/undo/tasks/purge.rake +9 -0
- data/lib/active_record/undo/undo_log.rb +30 -0
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo.rb +77 -0
- data/lib/tasks/active_record_undo_tasks.rake +4 -0
- metadata +10 -1
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :retention_period, :current_user_method, :current_tenant_method
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@retention_period = 30.days
|
|
10
|
+
@current_user_method = nil
|
|
11
|
+
@current_tenant_method = nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
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,31 +2,72 @@
|
|
|
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
|
|
8
10
|
module ModelExtension
|
|
9
11
|
extend ActiveSupport::Concern
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
module ClassMethods
|
|
12
14
|
# Accept a custom column parameter, defaulting to :deleted_at
|
|
13
15
|
def acts_as_undoable(column: :deleted_at)
|
|
14
16
|
include InstanceMethods
|
|
15
17
|
|
|
18
|
+
define_undo_attributes!(column)
|
|
19
|
+
define_undo_scopes!
|
|
20
|
+
|
|
21
|
+
ActiveRecord::Undo.registered_models << self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def define_undo_attributes!(column)
|
|
16
27
|
class_attribute :undoable_column
|
|
17
28
|
self.undoable_column = column.to_sym
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def define_undo_scopes!
|
|
32
|
+
define_basic_scopes!
|
|
33
|
+
define_expired_scope!
|
|
34
|
+
end
|
|
18
35
|
|
|
19
|
-
|
|
36
|
+
def define_basic_scopes!
|
|
20
37
|
scope :kept, -> { where(undoable_column => nil) }
|
|
21
38
|
scope :soft_deleted, -> { where.not(undoable_column => nil) }
|
|
22
39
|
end
|
|
40
|
+
|
|
41
|
+
def define_expired_scope!
|
|
42
|
+
scope :expired, lambda {
|
|
43
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
44
|
+
if period
|
|
45
|
+
where("#{table_name}.#{undoable_column} < ?", Time.current - period)
|
|
46
|
+
else
|
|
47
|
+
none
|
|
48
|
+
end
|
|
49
|
+
}
|
|
50
|
+
end
|
|
23
51
|
end
|
|
24
52
|
|
|
25
53
|
module InstanceMethods
|
|
54
|
+
include AttributionHelper
|
|
55
|
+
include TenantVerification
|
|
56
|
+
|
|
26
57
|
def soft_deleted?
|
|
27
58
|
public_send(self.class.undoable_column).present?
|
|
28
59
|
end
|
|
29
60
|
|
|
61
|
+
def expired?
|
|
62
|
+
return false unless soft_deleted?
|
|
63
|
+
|
|
64
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
65
|
+
return false unless period
|
|
66
|
+
|
|
67
|
+
timestamp = public_send(self.class.undoable_column)
|
|
68
|
+
timestamp && timestamp < Time.current - period
|
|
69
|
+
end
|
|
70
|
+
|
|
30
71
|
def undoable?
|
|
31
72
|
return false unless soft_deleted?
|
|
32
73
|
|
|
@@ -36,36 +77,28 @@ module ActiveRecord
|
|
|
36
77
|
)
|
|
37
78
|
end
|
|
38
79
|
|
|
39
|
-
def soft_delete!
|
|
80
|
+
def soft_delete!(whodunnit: nil, tenant: nil)
|
|
40
81
|
ensure_undoable_column_exists!
|
|
41
82
|
return false if soft_deleted?
|
|
42
83
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
transaction do
|
|
47
|
-
undo_log = ActiveRecord::Undo::UndoLog.create!
|
|
48
|
-
soft_delete_cascade_internal!(timestamp, undo_log)
|
|
49
|
-
undo_log.save!
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
undo_log
|
|
84
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
85
|
+
attrs = build_undo_log_attributes(whodunnit, tenant)
|
|
86
|
+
execute_soft_delete!(attrs)
|
|
53
87
|
end
|
|
54
88
|
|
|
55
|
-
|
|
89
|
+
# rubocop:disable Naming/PredicateMethod
|
|
90
|
+
def restore!(whodunnit: nil)
|
|
56
91
|
ensure_undoable_column_exists!
|
|
57
92
|
return false unless soft_deleted?
|
|
58
93
|
|
|
94
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
59
95
|
log_item = find_latest_undo_log_item
|
|
96
|
+
log_item ? restore_from_log!(log_item.undo_log, whodunnit) : direct_restore!
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
else
|
|
64
|
-
column_name = self.class.undoable_column
|
|
65
|
-
update_columns(column_name => nil, updated_at: Time.current)
|
|
66
|
-
true
|
|
67
|
-
end
|
|
98
|
+
reload
|
|
99
|
+
true
|
|
68
100
|
end
|
|
101
|
+
# rubocop:enable Naming/PredicateMethod
|
|
69
102
|
|
|
70
103
|
private
|
|
71
104
|
|
|
@@ -88,6 +121,41 @@ module ActiveRecord
|
|
|
88
121
|
def soft_delete_cascade_internal!(timestamp, undo_log)
|
|
89
122
|
CascadeHandler.new(self).soft_delete_with_cascade!(timestamp, undo_log)
|
|
90
123
|
end
|
|
124
|
+
|
|
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!
|
|
140
|
+
end
|
|
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
|
|
91
159
|
end
|
|
92
160
|
end
|
|
93
161
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'active_job'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# ActiveJob is not available
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
if defined?(ActiveJob::Base)
|
|
10
|
+
module ActiveRecord
|
|
11
|
+
module Undo
|
|
12
|
+
class PurgeJob < ActiveJob::Base
|
|
13
|
+
queue_as :default
|
|
14
|
+
|
|
15
|
+
def perform(batch_size: 1000)
|
|
16
|
+
ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'purger/reflection_helper'
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Undo
|
|
7
|
+
class Purger
|
|
8
|
+
extend ReflectionHelper
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def purge_expired!(batch_size: 1000)
|
|
12
|
+
purge_expired_logs!(batch_size)
|
|
13
|
+
purge_expired_records!(batch_size)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def purge_expired_logs!(batch_size)
|
|
19
|
+
loop do
|
|
20
|
+
log_ids = current_tenant_log_scope.limit(batch_size).pluck(:id)
|
|
21
|
+
break if log_ids.empty?
|
|
22
|
+
|
|
23
|
+
UndoLogItem.where(undo_log_id: log_ids).delete_all
|
|
24
|
+
UndoLog.where(id: log_ids).delete_all
|
|
25
|
+
end
|
|
26
|
+
end
|
|
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
|
+
|
|
44
|
+
def purge_expired_records!(batch_size)
|
|
45
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
46
|
+
return unless period
|
|
47
|
+
|
|
48
|
+
ActiveRecord::Undo.undoable_models.each do |model|
|
|
49
|
+
purge_model_expired_records!(model, batch_size)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def purge_model_expired_records!(model, batch_size)
|
|
54
|
+
pk = model.primary_key
|
|
55
|
+
loop do
|
|
56
|
+
expired_ids = scoped_model_expired(model).limit(batch_size).pluck(pk)
|
|
57
|
+
break if expired_ids.empty?
|
|
58
|
+
|
|
59
|
+
purge_records_with_cascade!(model, expired_ids, batch_size)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
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
|
+
|
|
90
|
+
def purge_records_with_cascade!(model, record_ids, batch_size)
|
|
91
|
+
return if record_ids.empty?
|
|
92
|
+
|
|
93
|
+
nullify_dependent_associations!(model, record_ids)
|
|
94
|
+
purge_cascading_associations!(model, record_ids, batch_size)
|
|
95
|
+
delete_records!(model, record_ids)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def nullify_dependent_associations!(model, record_ids)
|
|
99
|
+
nullify_reflections(model).each do |reflection|
|
|
100
|
+
child_query_for(model, reflection, record_ids).update_all(reflection.foreign_key => nil)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def purge_cascading_associations!(model, record_ids, batch_size)
|
|
105
|
+
cascade_reflections(model).each do |reflection|
|
|
106
|
+
query = child_query_for(model, reflection, record_ids)
|
|
107
|
+
primary_key = reflection.klass.primary_key
|
|
108
|
+
|
|
109
|
+
query.pluck(primary_key).each_slice(batch_size) do |child_ids|
|
|
110
|
+
purge_records_with_cascade!(reflection.klass, child_ids, batch_size)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def child_query_for(model, reflection, record_ids)
|
|
116
|
+
query = reflection.klass.unscoped.where(reflection.foreign_key => record_ids)
|
|
117
|
+
query = query.where(reflection.type => model.name) if reflection.type
|
|
118
|
+
query
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def delete_records!(model, record_ids)
|
|
122
|
+
model.unscoped.where(model.primary_key => record_ids).delete_all
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :active_record_undo do
|
|
4
|
+
desc 'Purge expired soft-deleted records and undo logs'
|
|
5
|
+
task purge_expired: :environment do
|
|
6
|
+
batch_size = ENV['BATCH_SIZE'] ? ENV['BATCH_SIZE'].to_i : 1000
|
|
7
|
+
ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -8,8 +8,38 @@ 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
|
+
|
|
30
|
+
scope :expired, lambda {
|
|
31
|
+
period = ActiveRecord::Undo.config.retention_period
|
|
32
|
+
if period
|
|
33
|
+
where('created_at < ?', Time.current - period)
|
|
34
|
+
else
|
|
35
|
+
none
|
|
36
|
+
end
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
# Restores all records associated with this deletion batch
|
|
12
40
|
def restore!
|
|
41
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
42
|
+
|
|
13
43
|
transaction do
|
|
14
44
|
# Reverse order ensures child records are restored before or after parents as needed
|
|
15
45
|
undo_log_items.reverse_each(&:restore_item!)
|
data/lib/active_record/undo.rb
CHANGED
|
@@ -9,15 +9,92 @@ rescue LoadError => e
|
|
|
9
9
|
"Please add 'activerecord' to your Gemfile. (Original error: #{e.message})"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
require 'set'
|
|
12
13
|
require_relative 'undo/version'
|
|
13
14
|
require_relative 'undo/engine' if defined?(Rails::Engine)
|
|
15
|
+
require_relative 'undo/configuration'
|
|
14
16
|
require_relative 'undo/model_extension'
|
|
15
17
|
require_relative 'undo/undo_log'
|
|
16
18
|
require_relative 'undo/undo_log_item'
|
|
19
|
+
require_relative 'undo/purger'
|
|
20
|
+
require_relative 'undo/purge_job'
|
|
17
21
|
|
|
18
22
|
module ActiveRecord
|
|
19
23
|
module Undo
|
|
20
24
|
class Error < StandardError; end
|
|
25
|
+
class SecurityError < Error; end
|
|
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
|
+
|
|
44
|
+
def config
|
|
45
|
+
@config ||= Configuration.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configure
|
|
49
|
+
yield(config)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def registered_models
|
|
53
|
+
@registered_models ||= Set.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def undoable_models
|
|
57
|
+
eager_load_rails!
|
|
58
|
+
models = registered_models.to_a
|
|
59
|
+
if defined?(ActiveRecord::Base)
|
|
60
|
+
models += ActiveRecord::Base.descendants.select { |m| m.respond_to?(:undoable_column) }
|
|
61
|
+
end
|
|
62
|
+
models.uniq
|
|
63
|
+
end
|
|
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
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def eager_load_rails!
|
|
91
|
+
return unless defined?(Rails) && Rails.application
|
|
92
|
+
|
|
93
|
+
Rails.application.eager_load!
|
|
94
|
+
rescue StandardError
|
|
95
|
+
# Safe fallback if Rails eager loading fails in test environments
|
|
96
|
+
end
|
|
97
|
+
end
|
|
21
98
|
end
|
|
22
99
|
end
|
|
23
100
|
|
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,15 +137,24 @@ 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
|
|
143
144
|
- lib/active_record/undo/cascade_handler/record_updater.rb
|
|
145
|
+
- lib/active_record/undo/configuration.rb
|
|
144
146
|
- lib/active_record/undo/engine.rb
|
|
145
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
|
|
150
|
+
- lib/active_record/undo/purge_job.rb
|
|
151
|
+
- lib/active_record/undo/purger.rb
|
|
152
|
+
- lib/active_record/undo/purger/reflection_helper.rb
|
|
153
|
+
- lib/active_record/undo/tasks/purge.rake
|
|
146
154
|
- lib/active_record/undo/undo_log.rb
|
|
147
155
|
- lib/active_record/undo/undo_log_item.rb
|
|
148
156
|
- lib/active_record/undo/version.rb
|
|
157
|
+
- lib/tasks/active_record_undo_tasks.rake
|
|
149
158
|
- sig/active_record/undo.rbs
|
|
150
159
|
homepage: https://github.com/saurabh-activecode/active_record-undo
|
|
151
160
|
licenses:
|