whodunit 0.1.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 +7 -0
- data/.codeclimate.yml +50 -0
- data/.rubocop.yml +106 -0
- data/.yardopts +12 -0
- data/CHANGELOG.md +44 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +21 -0
- data/README.md +376 -0
- data/Rakefile +19 -0
- data/gemfiles/rails_7_2.gemfile +15 -0
- data/gemfiles/rails_8_2.gemfile +15 -0
- data/gemfiles/rails_edge.gemfile +15 -0
- data/lib/whodunit/controller_methods.rb +196 -0
- data/lib/whodunit/current.rb +78 -0
- data/lib/whodunit/migration_helpers.rb +229 -0
- data/lib/whodunit/railtie.rb +49 -0
- data/lib/whodunit/soft_delete_detector.rb +119 -0
- data/lib/whodunit/stampable.rb +184 -0
- data/lib/whodunit/version.rb +5 -0
- data/lib/whodunit.rb +121 -0
- data/sig/whodunit.rbs +4 -0
- metadata +254 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
# Thread-safe current user context using Rails CurrentAttributes.
|
5
|
+
#
|
6
|
+
# This class provides a thread-safe way to store the current user for auditing purposes.
|
7
|
+
# It leverages Rails' CurrentAttributes functionality to ensure proper isolation
|
8
|
+
# across threads and requests.
|
9
|
+
#
|
10
|
+
# @example Setting a user
|
11
|
+
# Whodunit::Current.user = User.find(123)
|
12
|
+
# Whodunit::Current.user = 123 # Can also set by ID directly
|
13
|
+
#
|
14
|
+
# @example Getting the user ID
|
15
|
+
# Whodunit::Current.user_id # => 123
|
16
|
+
#
|
17
|
+
# @example In a controller
|
18
|
+
# class ApplicationController < ActionController::Base
|
19
|
+
# before_action :set_current_user
|
20
|
+
#
|
21
|
+
# private
|
22
|
+
#
|
23
|
+
# def set_current_user
|
24
|
+
# Whodunit::Current.user = current_user
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# @since 0.1.0
|
29
|
+
class Current < ActiveSupport::CurrentAttributes
|
30
|
+
# @!attribute [rw] user
|
31
|
+
# @return [Integer, nil] the current user ID
|
32
|
+
attribute :user
|
33
|
+
|
34
|
+
class << self
|
35
|
+
# Store the original user= method before we override it
|
36
|
+
alias original_user_assignment user=
|
37
|
+
|
38
|
+
# Set the current user by object or ID.
|
39
|
+
#
|
40
|
+
# Accepts either a user object (responds to #id) or a user ID directly.
|
41
|
+
# The user ID is stored for database operations.
|
42
|
+
#
|
43
|
+
# @param user [Object, Integer, nil] user object or user ID
|
44
|
+
# @return [Integer, nil] the stored user ID
|
45
|
+
# @example
|
46
|
+
# Whodunit::Current.user = User.find(123)
|
47
|
+
# Whodunit::Current.user = 123
|
48
|
+
# Whodunit::Current.user = nil # Clear current user
|
49
|
+
def user=(user)
|
50
|
+
value = user.respond_to?(:id) ? user.id : user
|
51
|
+
original_user_assignment(value)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Override reset to ensure our custom setter is preserved
|
55
|
+
def reset
|
56
|
+
super
|
57
|
+
# Redefine our custom setter after reset
|
58
|
+
_redefine_user_setter
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def _redefine_user_setter
|
64
|
+
# Nothing needed here since we're using original_user_assignment
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Get the current user ID for database storage.
|
69
|
+
#
|
70
|
+
# @return [Integer, nil] the current user ID
|
71
|
+
# @example
|
72
|
+
# Whodunit::Current.user = User.find(123)
|
73
|
+
# Whodunit::Current.user_id # => 123
|
74
|
+
def self.user_id
|
75
|
+
user
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,229 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
# Database migration helpers for adding whodunit stamp columns.
|
5
|
+
#
|
6
|
+
# This module provides convenient methods for adding creator/updater/deleter
|
7
|
+
# tracking columns to database tables. It intelligently detects soft-delete
|
8
|
+
# implementations and adds appropriate indexes for performance.
|
9
|
+
#
|
10
|
+
# @example Add stamps to existing table
|
11
|
+
# class AddWhodunitStampsToPosts < ActiveRecord::Migration[7.0]
|
12
|
+
# def change
|
13
|
+
# add_whodunit_stamps :posts
|
14
|
+
# # Adds creator_id, updater_id columns
|
15
|
+
# # Adds deleter_id if soft-delete detected
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# @example Add stamps to new table
|
20
|
+
# class CreatePosts < ActiveRecord::Migration[7.0]
|
21
|
+
# def change
|
22
|
+
# create_table :posts do |t|
|
23
|
+
# t.string :title
|
24
|
+
# t.text :body
|
25
|
+
# t.whodunit_stamps
|
26
|
+
# t.timestamps
|
27
|
+
# end
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# @example Custom data types
|
32
|
+
# add_whodunit_stamps :posts,
|
33
|
+
# creator_type: :string,
|
34
|
+
# updater_type: :uuid,
|
35
|
+
# include_deleter: true
|
36
|
+
#
|
37
|
+
# @since 0.1.0
|
38
|
+
module MigrationHelpers
|
39
|
+
# Add creator/updater stamp columns to an existing table.
|
40
|
+
#
|
41
|
+
# This method adds the configured creator and updater columns to an existing table.
|
42
|
+
# It optionally adds a deleter column based on soft-delete detection or explicit configuration.
|
43
|
+
# Indexes are automatically added for performance.
|
44
|
+
#
|
45
|
+
# @param table_name [Symbol] the table to add stamps to
|
46
|
+
# @param include_deleter [Symbol, Boolean] :auto to auto-detect soft-delete,
|
47
|
+
# true to force inclusion, false to exclude
|
48
|
+
# @param creator_type [Symbol, nil] data type for creator column (defaults to configured type)
|
49
|
+
# @param updater_type [Symbol, nil] data type for updater column (defaults to configured type)
|
50
|
+
# @param deleter_type [Symbol, nil] data type for deleter column (defaults to configured type)
|
51
|
+
# @return [void]
|
52
|
+
# @example Basic usage
|
53
|
+
# add_whodunit_stamps :posts
|
54
|
+
# @example With custom types
|
55
|
+
# add_whodunit_stamps :posts, creator_type: :string, updater_type: :uuid
|
56
|
+
# @example Force deleter column
|
57
|
+
# add_whodunit_stamps :posts, include_deleter: true
|
58
|
+
def add_whodunit_stamps(table_name, include_deleter: :auto, creator_type: nil, updater_type: nil, deleter_type: nil)
|
59
|
+
add_column table_name, Whodunit.creator_column, creator_type || Whodunit.creator_data_type, null: true
|
60
|
+
add_column table_name, Whodunit.updater_column, updater_type || Whodunit.updater_data_type, null: true
|
61
|
+
|
62
|
+
if should_include_deleter?(table_name, include_deleter)
|
63
|
+
add_column table_name, Whodunit.deleter_column, deleter_type || Whodunit.deleter_data_type, null: true
|
64
|
+
end
|
65
|
+
|
66
|
+
add_whodunit_indexes(table_name, include_deleter)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Remove stamp columns from an existing table.
|
70
|
+
#
|
71
|
+
# This method removes the configured creator, updater, and optionally deleter
|
72
|
+
# columns from an existing table. Only removes columns that actually exist.
|
73
|
+
#
|
74
|
+
# @param table_name [Symbol] the table to remove stamps from
|
75
|
+
# @param include_deleter [Symbol, Boolean] :auto to auto-detect soft-delete,
|
76
|
+
# true to force removal, false to exclude
|
77
|
+
# @param _options [Hash] additional options (reserved for future use)
|
78
|
+
# @return [void]
|
79
|
+
# @example Basic usage
|
80
|
+
# remove_whodunit_stamps :posts
|
81
|
+
# @example Force deleter removal
|
82
|
+
# remove_whodunit_stamps :posts, include_deleter: true
|
83
|
+
def remove_whodunit_stamps(table_name, include_deleter: :auto, **_options)
|
84
|
+
remove_column table_name, Whodunit.creator_column if column_exists?(table_name, Whodunit.creator_column)
|
85
|
+
remove_column table_name, Whodunit.updater_column if column_exists?(table_name, Whodunit.updater_column)
|
86
|
+
|
87
|
+
if should_include_deleter?(table_name, include_deleter) &&
|
88
|
+
column_exists?(table_name, Whodunit.deleter_column)
|
89
|
+
remove_column table_name, Whodunit.deleter_column
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Add stamp columns to a table definition or existing table.
|
94
|
+
#
|
95
|
+
# This method can be used in two ways:
|
96
|
+
# 1. Inside a create_table block (pass table definition as first argument)
|
97
|
+
# 2. As a standalone method in migrations (attempts to infer table name)
|
98
|
+
#
|
99
|
+
# @param table_def [ActiveRecord::ConnectionAdapters::TableDefinition, nil]
|
100
|
+
# the table definition (when used in create_table block) or nil
|
101
|
+
# @param include_deleter [Symbol, Boolean] :auto to auto-detect soft-delete,
|
102
|
+
# true to force inclusion, false to exclude
|
103
|
+
# @param creator_type [Symbol, nil] data type for creator column (defaults to configured type)
|
104
|
+
# @param updater_type [Symbol, nil] data type for updater column (defaults to configured type)
|
105
|
+
# @param deleter_type [Symbol, nil] data type for deleter column (defaults to configured type)
|
106
|
+
# @return [void]
|
107
|
+
# @example In create_table block
|
108
|
+
# create_table :posts do |t|
|
109
|
+
# t.string :title
|
110
|
+
# t.whodunit_stamps
|
111
|
+
# end
|
112
|
+
# @example Standalone (infers table from migration name)
|
113
|
+
# def change
|
114
|
+
# whodunit_stamps # Adds to inferred table
|
115
|
+
# end
|
116
|
+
def whodunit_stamps(table_def = nil, include_deleter: :auto, creator_type: nil, updater_type: nil,
|
117
|
+
deleter_type: nil)
|
118
|
+
if table_def.nil?
|
119
|
+
handle_migration_stamps(include_deleter, creator_type, updater_type, deleter_type)
|
120
|
+
else
|
121
|
+
handle_table_definition_stamps(table_def, include_deleter, creator_type, updater_type, deleter_type)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
# Handle stamps when called as migration method
|
128
|
+
def handle_migration_stamps(include_deleter, creator_type, updater_type, deleter_type)
|
129
|
+
table_name = infer_table_name_from_migration
|
130
|
+
return unless table_name
|
131
|
+
|
132
|
+
add_whodunit_stamps(table_name, include_deleter: include_deleter, creator_type: creator_type,
|
133
|
+
updater_type: updater_type, deleter_type: deleter_type)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Handle stamps when called within create_table block
|
137
|
+
def handle_table_definition_stamps(table_def, include_deleter, creator_type, updater_type, deleter_type)
|
138
|
+
table_def.column Whodunit.creator_column, creator_type || Whodunit.creator_data_type, null: true
|
139
|
+
table_def.column Whodunit.updater_column, updater_type || Whodunit.updater_data_type, null: true
|
140
|
+
|
141
|
+
if should_include_deleter_for_new_table?(include_deleter)
|
142
|
+
table_def.column Whodunit.deleter_column, deleter_type || Whodunit.deleter_data_type, null: true
|
143
|
+
end
|
144
|
+
|
145
|
+
add_whodunit_indexes_for_create_table(table_def, include_deleter)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Determine if deleter column should be included
|
149
|
+
def should_include_deleter?(table_name, include_deleter)
|
150
|
+
case include_deleter
|
151
|
+
when :auto
|
152
|
+
soft_delete_detected_for_table?(table_name)
|
153
|
+
when true
|
154
|
+
true
|
155
|
+
else
|
156
|
+
false
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# For new tables, be more conservative with auto-detection
|
161
|
+
def should_include_deleter_for_new_table?(include_deleter)
|
162
|
+
case include_deleter
|
163
|
+
when true
|
164
|
+
true
|
165
|
+
else
|
166
|
+
false # Don't auto-add for new tables, let user be explicit
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Detect soft-delete patterns in existing table
|
171
|
+
def soft_delete_detected_for_table?(table_name)
|
172
|
+
return false unless table_exists?(table_name)
|
173
|
+
|
174
|
+
soft_delete_columns = %w[
|
175
|
+
deleted_at destroyed_at discarded_at archived_at
|
176
|
+
soft_deleted_at soft_destroyed_at removed_at
|
177
|
+
]
|
178
|
+
|
179
|
+
soft_delete_columns.any? { |col| column_exists?(table_name, col) || column_exists?(table_name, col.to_sym) }
|
180
|
+
end
|
181
|
+
|
182
|
+
# Add indexes for performance
|
183
|
+
def add_whodunit_indexes(table_name, include_deleter)
|
184
|
+
add_index table_name, Whodunit.creator_column, name: "index_#{table_name}_on_creator"
|
185
|
+
add_index table_name, Whodunit.updater_column, name: "index_#{table_name}_on_updater"
|
186
|
+
|
187
|
+
return unless should_include_deleter?(table_name, include_deleter)
|
188
|
+
|
189
|
+
add_index table_name, Whodunit.deleter_column, name: "index_#{table_name}_on_deleter"
|
190
|
+
end
|
191
|
+
|
192
|
+
# Add indexes within create_table block
|
193
|
+
def add_whodunit_indexes_for_create_table(table_def, include_deleter)
|
194
|
+
# Skip indexes for table definitions that don't support them
|
195
|
+
return unless table_def.respond_to?(:index)
|
196
|
+
|
197
|
+
table_name = table_def.respond_to?(:name) ? table_def.name : "table"
|
198
|
+
table_def.index Whodunit.creator_column, name: "index_#{table_name}_on_creator"
|
199
|
+
table_def.index Whodunit.updater_column, name: "index_#{table_name}_on_updater"
|
200
|
+
|
201
|
+
return unless should_include_deleter_for_new_table?(include_deleter)
|
202
|
+
|
203
|
+
table_def.index Whodunit.deleter_column, name: "index_#{table_name}_on_deleter"
|
204
|
+
end
|
205
|
+
|
206
|
+
# Attempt to infer table name from migration class name
|
207
|
+
def infer_table_name_from_migration
|
208
|
+
return nil unless respond_to?(:migration_name)
|
209
|
+
|
210
|
+
# Extract table name from migration names like CreatePosts, AddStampsToPosts
|
211
|
+
case migration_name
|
212
|
+
when /^Create(\w+)$/
|
213
|
+
table_name = ::Regexp.last_match(1)
|
214
|
+
table_name.respond_to?(:underscore) ? table_name.underscore.pluralize : "#{table_name.downcase}s"
|
215
|
+
when /^Add\w*To(\w+)$/
|
216
|
+
table_name = ::Regexp.last_match(1)
|
217
|
+
table_name.respond_to?(:underscore) ? table_name.underscore : table_name.downcase
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Get migration name from class
|
222
|
+
def migration_name
|
223
|
+
self.class.name.demodulize
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Extend ActiveRecord::Migration when available
|
229
|
+
ActiveRecord::Migration.include(Whodunit::MigrationHelpers) if defined?(ActiveRecord::Migration)
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
# Rails integration for automatic setup of Whodunit functionality.
|
5
|
+
#
|
6
|
+
# This Railtie automatically extends ActiveRecord migrations with MigrationHelpers
|
7
|
+
# and includes ControllerMethods in ActionController::Base when Rails is available.
|
8
|
+
# It provides seamless integration without requiring manual setup.
|
9
|
+
#
|
10
|
+
# @example Automatic integration (no setup required)
|
11
|
+
# # In a Rails app, this automatically provides:
|
12
|
+
# # - Migration helpers (add_whodunit_stamps, etc.)
|
13
|
+
# # - Controller methods (set_whodunit_user, etc.)
|
14
|
+
#
|
15
|
+
# @note When Rails is not available, this class safely inherits from Object
|
16
|
+
# and does not cause any errors.
|
17
|
+
#
|
18
|
+
# @since 0.1.0
|
19
|
+
class Railtie < (defined?(Rails::Railtie) ? Rails::Railtie : Object)
|
20
|
+
if defined?(Rails::Railtie)
|
21
|
+
railtie_name :whodunit
|
22
|
+
|
23
|
+
# Extend ActiveRecord with migration helpers.
|
24
|
+
#
|
25
|
+
# This initializer adds the MigrationHelpers module to ActiveRecord,
|
26
|
+
# making methods like add_whodunit_stamps available in migrations.
|
27
|
+
#
|
28
|
+
# @api private
|
29
|
+
initializer "whodunit.extend_active_record" do |_app|
|
30
|
+
ActiveSupport.on_load(:active_record) do
|
31
|
+
extend Whodunit::MigrationHelpers
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Include controller methods in ActionController::Base.
|
36
|
+
#
|
37
|
+
# This initializer adds the ControllerMethods module to ActionController::Base,
|
38
|
+
# making methods like set_whodunit_user and current_whodunit_user available
|
39
|
+
# in all controllers.
|
40
|
+
#
|
41
|
+
# @api private
|
42
|
+
initializer "whodunit.extend_action_controller" do |_app|
|
43
|
+
ActiveSupport.on_load(:action_controller_base) do
|
44
|
+
include Whodunit::ControllerMethods
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
# Smart detection of soft-delete implementations.
|
5
|
+
#
|
6
|
+
# This class provides multi-layered detection for various soft-delete implementations
|
7
|
+
# including popular gems like Discard and Paranoia, as well as custom implementations.
|
8
|
+
#
|
9
|
+
# @example Check if a model has soft-delete enabled
|
10
|
+
# class Post < ApplicationRecord
|
11
|
+
# # has deleted_at column
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# Whodunit::SoftDeleteDetector.enabled_for?(Post) # => true
|
15
|
+
#
|
16
|
+
# @example With Discard gem
|
17
|
+
# class Post < ApplicationRecord
|
18
|
+
# include Discard::Model
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# Whodunit::SoftDeleteDetector.enabled_for?(Post) # => true
|
22
|
+
#
|
23
|
+
# @since 0.1.0
|
24
|
+
class SoftDeleteDetector
|
25
|
+
# Main detection method that checks if soft-delete is enabled for a model.
|
26
|
+
#
|
27
|
+
# Uses multiple detection strategies:
|
28
|
+
# 1. Gem-based detection (Discard, Paranoia, etc.)
|
29
|
+
# 2. Column pattern detection (deleted_at, discarded_at, etc.)
|
30
|
+
# 3. Method-based detection (respond_to? soft-delete methods)
|
31
|
+
#
|
32
|
+
# @param model [Class] the ActiveRecord model class to check
|
33
|
+
# @return [Boolean] true if soft-delete is detected, false otherwise
|
34
|
+
# @example
|
35
|
+
# Whodunit::SoftDeleteDetector.enabled_for?(Post) # => true
|
36
|
+
# Whodunit::SoftDeleteDetector.enabled_for?(User) # => false
|
37
|
+
def self.enabled_for?(model)
|
38
|
+
return false unless model.respond_to?(:columns)
|
39
|
+
|
40
|
+
gem_based_detection?(model) ||
|
41
|
+
column_pattern_detection(model) ||
|
42
|
+
method_based_detection(model)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Detect popular soft-delete gems.
|
46
|
+
#
|
47
|
+
# Detects the following gems:
|
48
|
+
# - Discard (checks for Discard module inclusion)
|
49
|
+
# - Paranoia (checks for paranoid? method and Paranoid ancestors)
|
50
|
+
# - ActsAsParanoid (checks for acts_as_paranoid method)
|
51
|
+
#
|
52
|
+
# @param model [Class] the ActiveRecord model class to check
|
53
|
+
# @return [Boolean] true if a gem-based soft-delete is detected
|
54
|
+
# @api private
|
55
|
+
def self.gem_based_detection?(model)
|
56
|
+
# Discard gem
|
57
|
+
return true if model.included_modules.any? { |mod| mod.to_s.include?("Discard") }
|
58
|
+
|
59
|
+
# Paranoia gem
|
60
|
+
return true if model.respond_to?(:paranoid?) ||
|
61
|
+
model.respond_to?(:acts_as_paranoid) ||
|
62
|
+
model.ancestors.any? { |a| a.to_s.include?("Paranoid") }
|
63
|
+
|
64
|
+
# ActsAsParanoid (older version)
|
65
|
+
return true if model.respond_to?(:acts_as_paranoid)
|
66
|
+
|
67
|
+
false
|
68
|
+
end
|
69
|
+
|
70
|
+
# Detect soft-delete by column patterns.
|
71
|
+
#
|
72
|
+
# Looks for common soft-delete column names:
|
73
|
+
# - deleted_at, destroyed_at, discarded_at, archived_at
|
74
|
+
# - soft_deleted_at, soft_destroyed_at, removed_at
|
75
|
+
#
|
76
|
+
# Only considers timestamp columns (datetime, timestamp, date).
|
77
|
+
#
|
78
|
+
# @param model [Class] the ActiveRecord model class to check
|
79
|
+
# @return [Boolean] true if soft-delete columns are found
|
80
|
+
# @api private
|
81
|
+
def self.column_pattern_detection(model)
|
82
|
+
soft_delete_columns = %w[
|
83
|
+
deleted_at destroyed_at discarded_at archived_at
|
84
|
+
soft_deleted_at soft_destroyed_at removed_at
|
85
|
+
]
|
86
|
+
|
87
|
+
model.columns.any? do |column|
|
88
|
+
soft_delete_columns.include?(column.name) &&
|
89
|
+
timestamp_column?(column)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Detect soft-delete by method presence.
|
94
|
+
#
|
95
|
+
# Checks if the model responds to common soft-delete method names.
|
96
|
+
# This catches custom implementations that follow standard naming conventions.
|
97
|
+
#
|
98
|
+
# @param model [Class] the ActiveRecord model class to check
|
99
|
+
# @return [Boolean] true if soft-delete methods are found
|
100
|
+
# @api private
|
101
|
+
def self.method_based_detection(model)
|
102
|
+
soft_delete_methods = %w[
|
103
|
+
deleted_at destroyed_at discarded_at archived_at
|
104
|
+
soft_deleted_at soft_destroyed_at removed_at
|
105
|
+
]
|
106
|
+
|
107
|
+
soft_delete_methods.any? { |method| model.respond_to?(method) }
|
108
|
+
end
|
109
|
+
|
110
|
+
# Check if a column is a timestamp type.
|
111
|
+
#
|
112
|
+
# @param column [Object] the column object (responds to #type)
|
113
|
+
# @return [Boolean] true if the column is a timestamp type
|
114
|
+
# @api private
|
115
|
+
def self.timestamp_column?(column)
|
116
|
+
%i[datetime timestamp date].include?(column.type)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
# Main concern for adding creator/updater/deleter tracking to ActiveRecord models.
|
5
|
+
#
|
6
|
+
# This module provides automatic tracking of who created, updated, and deleted records.
|
7
|
+
# It intelligently sets up callbacks and associations based on available columns and
|
8
|
+
# soft-delete detection.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# class Post < ApplicationRecord
|
12
|
+
# include Whodunit::Stampable
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# # Requires creator_id and/or updater_id columns
|
16
|
+
# # Automatically adds deleter_id tracking if soft-delete is detected
|
17
|
+
#
|
18
|
+
# @example Migration
|
19
|
+
# class CreatePosts < ActiveRecord::Migration[7.0]
|
20
|
+
# def change
|
21
|
+
# create_table :posts do |t|
|
22
|
+
# t.string :title
|
23
|
+
# t.text :body
|
24
|
+
# t.whodunit_stamps # Adds creator_id, updater_id columns
|
25
|
+
# t.timestamps
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# @example Manual deleter tracking
|
31
|
+
# class Post < ApplicationRecord
|
32
|
+
# include Whodunit::Stampable
|
33
|
+
#
|
34
|
+
# # Force enable deleter tracking even without soft-delete
|
35
|
+
# enable_whodunit_deleter!
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# @since 0.1.0
|
39
|
+
module Stampable
|
40
|
+
extend ActiveSupport::Concern
|
41
|
+
|
42
|
+
included do
|
43
|
+
# Set up callbacks
|
44
|
+
before_create :set_whodunit_creator, if: :creator_column?
|
45
|
+
before_update :set_whodunit_updater, if: :updater_column?
|
46
|
+
|
47
|
+
# Only add destroy callback if soft-delete is detected
|
48
|
+
if Whodunit.auto_detect_soft_delete &&
|
49
|
+
Whodunit::SoftDeleteDetector.enabled_for?(self)
|
50
|
+
before_destroy :set_whodunit_deleter, if: :deleter_column?
|
51
|
+
end
|
52
|
+
|
53
|
+
# Set up associations - call on the class
|
54
|
+
setup_whodunit_associations
|
55
|
+
end
|
56
|
+
|
57
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
58
|
+
def soft_delete_enabled?
|
59
|
+
@soft_delete_enabled ||= Whodunit::SoftDeleteDetector.enabled_for?(self)
|
60
|
+
end
|
61
|
+
|
62
|
+
def enable_whodunit_deleter!
|
63
|
+
before_destroy :set_whodunit_deleter, if: :deleter_column?
|
64
|
+
setup_deleter_association
|
65
|
+
@soft_delete_enabled = true
|
66
|
+
end
|
67
|
+
|
68
|
+
def disable_whodunit_deleter!
|
69
|
+
skip_callback :destroy, :before, :set_whodunit_deleter
|
70
|
+
@soft_delete_enabled = false
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def setup_whodunit_associations
|
76
|
+
setup_creator_association if creator_column_exists?
|
77
|
+
setup_updater_association if updater_column_exists?
|
78
|
+
setup_deleter_association if deleter_column_exists? && soft_delete_enabled?
|
79
|
+
end
|
80
|
+
|
81
|
+
def creator_column_exists?
|
82
|
+
column_names.include?(Whodunit.creator_column.to_s)
|
83
|
+
end
|
84
|
+
|
85
|
+
def updater_column_exists?
|
86
|
+
column_names.include?(Whodunit.updater_column.to_s)
|
87
|
+
end
|
88
|
+
|
89
|
+
def deleter_column_exists?
|
90
|
+
column_names.include?(Whodunit.deleter_column.to_s)
|
91
|
+
end
|
92
|
+
|
93
|
+
def setup_creator_association
|
94
|
+
belongs_to :creator,
|
95
|
+
class_name: Whodunit.user_class_name,
|
96
|
+
foreign_key: Whodunit.creator_column,
|
97
|
+
optional: true
|
98
|
+
end
|
99
|
+
|
100
|
+
def setup_updater_association
|
101
|
+
belongs_to :updater,
|
102
|
+
class_name: Whodunit.user_class_name,
|
103
|
+
foreign_key: Whodunit.updater_column,
|
104
|
+
optional: true
|
105
|
+
end
|
106
|
+
|
107
|
+
def setup_deleter_association
|
108
|
+
belongs_to :deleter,
|
109
|
+
class_name: Whodunit.user_class_name,
|
110
|
+
foreign_key: Whodunit.deleter_column,
|
111
|
+
optional: true
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# @!group Callback Methods
|
116
|
+
|
117
|
+
# Set the creator ID when a record is created.
|
118
|
+
#
|
119
|
+
# This method is automatically called before_create if the model has a creator column.
|
120
|
+
# It sets the creator_id to the current user from Whodunit::Current.
|
121
|
+
#
|
122
|
+
# @return [void]
|
123
|
+
# @api private
|
124
|
+
def set_whodunit_creator
|
125
|
+
return unless Whodunit::Current.user_id
|
126
|
+
|
127
|
+
self[Whodunit.creator_column] = Whodunit::Current.user_id
|
128
|
+
end
|
129
|
+
|
130
|
+
# Set the updater ID when a record is updated.
|
131
|
+
#
|
132
|
+
# This method is automatically called before_update if the model has an updater column.
|
133
|
+
# It sets the updater_id to the current user from Whodunit::Current.
|
134
|
+
# Does not run on new records (creation).
|
135
|
+
#
|
136
|
+
# @return [void]
|
137
|
+
# @api private
|
138
|
+
def set_whodunit_updater
|
139
|
+
return unless Whodunit::Current.user_id
|
140
|
+
return if new_record? # Don't set updater on creation
|
141
|
+
|
142
|
+
self[Whodunit.updater_column] = Whodunit::Current.user_id
|
143
|
+
end
|
144
|
+
|
145
|
+
# Set the deleter ID when a record is destroyed.
|
146
|
+
#
|
147
|
+
# This method is automatically called before_destroy if the model has a deleter column
|
148
|
+
# and soft-delete is enabled. It sets the deleter_id to the current user from Whodunit::Current.
|
149
|
+
#
|
150
|
+
# @return [void]
|
151
|
+
# @api private
|
152
|
+
def set_whodunit_deleter
|
153
|
+
return unless Whodunit::Current.user_id
|
154
|
+
|
155
|
+
self[Whodunit.deleter_column] = Whodunit::Current.user_id
|
156
|
+
end
|
157
|
+
|
158
|
+
# @!group Column Presence Checks
|
159
|
+
|
160
|
+
# Check if the model has a creator column.
|
161
|
+
#
|
162
|
+
# @return [Boolean] true if the creator column exists
|
163
|
+
# @api private
|
164
|
+
def creator_column?
|
165
|
+
self.class.column_names.include?(Whodunit.creator_column.to_s)
|
166
|
+
end
|
167
|
+
|
168
|
+
# Check if the model has an updater column.
|
169
|
+
#
|
170
|
+
# @return [Boolean] true if the updater column exists
|
171
|
+
# @api private
|
172
|
+
def updater_column?
|
173
|
+
self.class.column_names.include?(Whodunit.updater_column.to_s)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Check if the model has a deleter column.
|
177
|
+
#
|
178
|
+
# @return [Boolean] true if the deleter column exists
|
179
|
+
# @api private
|
180
|
+
def deleter_column?
|
181
|
+
self.class.column_names.include?(Whodunit.deleter_column.to_s)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|