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.
@@ -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
@@ -14,6 +14,11 @@ module ActiveRecord
14
14
  end
15
15
  end
16
16
  end
17
+
18
+ rake_tasks do
19
+ path = root.join('lib', 'tasks', 'active_record_undo_tasks.rake')
20
+ load path if File.exist?(path)
21
+ end
17
22
  end
18
23
  end
19
24
  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
- class_methods do
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
- # Dynamic scopes using the configured column
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
- undo_log = nil
44
- timestamp = Time.current
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
- def restore!
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
- if log_item
62
- log_item.undo_log.restore!
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!)
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveRecord
5
5
  module Undo
6
- VERSION = '0.1.3'
6
+ VERSION = '0.1.5'
7
7
  end
8
8
  end
@@ -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
 
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ path = File.expand_path('../active_record/undo/tasks/purge.rake', __dir__)
4
+ load path if File.exist?(path)
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.3
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: