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.
@@ -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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class RestoresController < LogsController
6
+ def create
7
+ restore
8
+ end
9
+ end
10
+ end
11
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
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
+ end
8
+ end
9
+
10
+ post '/restore/:token', to: 'restores#create', as: :signed_restore
11
+ end
@@ -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 soft_delete!
76
- ensure_undoable_column_exists!
77
- return false if soft_deleted?
80
+ def undo_log
81
+ return nil unless persisted? && soft_deleted?
78
82
 
79
- undo_log = nil
80
- timestamp = Time.current
83
+ find_latest_undo_log_item&.undo_log
84
+ end
85
+ alias latest_undo_log undo_log
81
86
 
82
- transaction do
83
- undo_log = ActiveRecord::Undo::UndoLog.create!
84
- soft_delete_cascade_internal!(timestamp, undo_log)
85
- undo_log.save!
86
- end
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
- undo_log
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
- restore_internally!(find_latest_undo_log_item)
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 restore_internally!(log_item)
125
- if log_item
126
- log_item.undo_log.restore!
127
- else
128
- column_name = self.class.undoable_column
129
- update_columns(column_name => nil, updated_at: Time.current)
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 = UndoLog.expired.limit(batch_size).pluck(:id)
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
- primary_key = model.primary_key
54
+ pk = model.primary_key
35
55
  loop do
36
- expired_ids = model.expired.limit(batch_size).pluck(primary_key)
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