whodunit 0.2.1 → 0.3.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/.rubocop.yml +19 -0
- data/CHANGELOG.md +26 -0
- data/README.md +60 -1
- data/lib/whodunit/stampable.rb +29 -0
- data/lib/whodunit/version.rb +1 -1
- data/lib/whodunit.rb +154 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 323bc72651528d7bf5e205c0388462078acc718ea39b14b5775517644a2115fc
|
4
|
+
data.tar.gz: d20344c823d5716ae218497ca92f61f41c6660c8e8f27a5479b0e52bc64b6b25
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f893afd5307dfa5cdabd58c8de25baa2e979809f9d2c810cdb5dd029a90ebb07f79a61ab24c149e9545159aafef09b3494e2fe15cdee93a6c66e1ae35c44bd2e
|
7
|
+
data.tar.gz: 9df8c395516af0720e71fd2a5e8b03a3471f4a85684f8b8a7b274222f74a19feeb1bb9a2b5b3e4795fbcf8938aef769a87e1dd380668838198efad31215d4e3b
|
data/.rubocop.yml
CHANGED
@@ -38,9 +38,17 @@ Metrics/MethodLength:
|
|
38
38
|
Exclude:
|
39
39
|
- 'spec/**/*'
|
40
40
|
|
41
|
+
# Allow longer modules for main configuration
|
42
|
+
Metrics/ModuleLength:
|
43
|
+
Exclude:
|
44
|
+
- 'lib/whodunit.rb'
|
45
|
+
|
41
46
|
# RSpec specific configurations
|
42
47
|
RSpec/ExampleLength:
|
43
48
|
Max: 15
|
49
|
+
Exclude:
|
50
|
+
- 'spec/whodunit/reverse_associations_integration_spec.rb'
|
51
|
+
- 'spec/whodunit/reverse_associations_spec.rb'
|
44
52
|
|
45
53
|
RSpec/MultipleExpectations:
|
46
54
|
Max: 5
|
@@ -54,10 +62,21 @@ RSpec/ContextWording:
|
|
54
62
|
RSpec/DescribeClass:
|
55
63
|
Exclude:
|
56
64
|
- 'spec/integration/**/*'
|
65
|
+
- 'spec/whodunit/reverse_associations_integration_spec.rb'
|
57
66
|
|
58
67
|
RSpec/VerifiedDoubles:
|
59
68
|
Enabled: false
|
60
69
|
|
70
|
+
RSpec/MessageSpies:
|
71
|
+
Exclude:
|
72
|
+
- 'spec/whodunit/reverse_associations_integration_spec.rb'
|
73
|
+
- 'spec/whodunit/reverse_associations_spec.rb'
|
74
|
+
- 'spec/whodunit/stampable_spec.rb'
|
75
|
+
|
76
|
+
RSpec/SpecFilePathFormat:
|
77
|
+
Exclude:
|
78
|
+
- 'spec/whodunit/reverse_associations_spec.rb'
|
79
|
+
|
61
80
|
# Allow positional boolean arguments for standard Ruby method signatures
|
62
81
|
Style/OptionalBooleanParameter:
|
63
82
|
Exclude:
|
data/CHANGELOG.md
CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## [0.3.0] - 2025-01-24
|
11
|
+
|
12
|
+
### Added
|
13
|
+
|
14
|
+
- **Automatic Reverse Associations**: When models include `Whodunit::Stampable`, reverse associations are automatically created on the user class (e.g., `user.created_posts`, `user.updated_comments`, `user.deleted_documents`)
|
15
|
+
- **Model Registry System**: Tracks models that include Stampable for automatic reverse association setup
|
16
|
+
- **Per-Model Reverse Association Control**: Models can disable reverse associations with `disable_whodunit_reverse_associations!`
|
17
|
+
- **Reverse Association Configuration**: Global configuration options including `auto_setup_reverse_associations`, `reverse_association_prefix`, and `reverse_association_suffix`
|
18
|
+
- **Smart Model Capability Detection**: Automatically detects which associations to create based on model capabilities (creator, updater, deleter, soft-delete)
|
19
|
+
- **Custom Column Support**: Reverse associations respect per-model custom column configurations
|
20
|
+
- **Manual Setup Methods**: `setup_whodunit_reverse_associations!` and `setup_all_reverse_associations` for manual control
|
21
|
+
|
22
|
+
### Changed
|
23
|
+
|
24
|
+
- **Enhanced Stampable Module**: Now automatically registers models for reverse association setup when included
|
25
|
+
- **Improved Configuration**: Extended main configuration module with reverse association settings
|
26
|
+
- **Better Association Management**: Prevents duplicate associations and handles edge cases gracefully
|
27
|
+
|
28
|
+
### Features
|
29
|
+
|
30
|
+
- **Zero Configuration**: Reverse associations work automatically with sensible defaults
|
31
|
+
- **Thread Safe**: Built on existing thread-safe architecture
|
32
|
+
- **Flexible Naming**: Configurable prefixes and suffixes for association names
|
33
|
+
- **Comprehensive Testing**: Full test coverage for all reverse association functionality
|
34
|
+
- **RuboCop Compliant**: All code follows project style guidelines
|
35
|
+
|
10
36
|
## [0.2.1] - 2025-01-21
|
11
37
|
|
12
38
|
### Added
|
data/README.md
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
# Whodunit
|
2
2
|
|
3
|
+
[](https://badge.fury.io/rb/whodunit)
|
4
|
+
[](https://github.com/kanutocd/whodunit/actions)
|
5
|
+
[](https://codecov.io/gh/kanutocd/whodunit)
|
6
|
+
[](https://www.ruby-lang.org/en/)
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
8
|
+
|
3
9
|
Lightweight creator/updater/deleter tracking for Rails ActiveRecord models.
|
4
10
|
|
5
11
|
> **Fun Fact**: The term "whodunit" was coined by literary critic Donald Gordon in 1930 when reviewing a murder mystery novel for _American News of Books_. He described Milward Kennedy's _Half Mast Murder_ as "a satisfactory whodunit" - the first recorded use of this now-famous term for mystery stories! _([Source: Wikipedia](https://en.wikipedia.org/wiki/Whodunit))_
|
@@ -39,10 +45,13 @@ And then execute:
|
|
39
45
|
After installation, you have a few options:
|
40
46
|
|
41
47
|
1. **Generate Configuration & Setup** (Recommended):
|
48
|
+
|
42
49
|
```bash
|
43
50
|
whodunit install
|
44
51
|
```
|
52
|
+
|
45
53
|
This will:
|
54
|
+
|
46
55
|
- Create `config/initializers/whodunit.rb` with all available configuration options
|
47
56
|
- Optionally add `Whodunit::Stampable` to your `ApplicationRecord` for automatic stamping on all models
|
48
57
|
- Provide clear next steps for adding stamp columns to your database
|
@@ -123,6 +132,51 @@ user.updater # => User who last updated this record
|
|
123
132
|
user.deleter # => User who deleted this record (if soft-delete enabled)
|
124
133
|
```
|
125
134
|
|
135
|
+
## Reverse Associations (Automatic)
|
136
|
+
|
137
|
+
Whodunit automatically sets up reverse associations on your User model when you include `Whodunit::Stampable` in other models:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
# When you include Whodunit::Stampable in Post model:
|
141
|
+
class Post < ApplicationRecord
|
142
|
+
include Whodunit::Stampable
|
143
|
+
end
|
144
|
+
|
145
|
+
# Your User model automatically gets these associations:
|
146
|
+
user.created_posts # => Posts created by this user
|
147
|
+
user.updated_posts # => Posts last updated by this user
|
148
|
+
user.deleted_posts # => Posts deleted by this user (if soft-delete enabled)
|
149
|
+
```
|
150
|
+
|
151
|
+
### Configuring Reverse Associations
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
# config/initializers/whodunit.rb
|
155
|
+
Whodunit.configure do |config|
|
156
|
+
# Disable automatic reverse associations globally
|
157
|
+
config.auto_setup_reverse_associations = false
|
158
|
+
|
159
|
+
# Customize association names with prefix/suffix
|
160
|
+
config.reverse_association_prefix = "whodunit_"
|
161
|
+
config.reverse_association_suffix = "_tracked"
|
162
|
+
# Results in: user.whodunit_created_posts_tracked
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
### Per-Model Control
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
class Post < ApplicationRecord
|
170
|
+
include Whodunit::Stampable
|
171
|
+
|
172
|
+
# Disable reverse associations for this model only
|
173
|
+
disable_whodunit_reverse_associations!
|
174
|
+
end
|
175
|
+
|
176
|
+
# Or manually set up later
|
177
|
+
Post.setup_whodunit_reverse_associations!
|
178
|
+
```
|
179
|
+
|
126
180
|
## Soft-Delete Support
|
127
181
|
|
128
182
|
Whodunit automatically tracks who deleted records when using soft-delete. Simply configure your soft-delete column:
|
@@ -155,6 +209,11 @@ Whodunit.configure do |config|
|
|
155
209
|
config.soft_delete_column = :discarded_at # Default: nil
|
156
210
|
config.auto_inject_whodunit_stamps = false # Default: true
|
157
211
|
|
212
|
+
# Reverse association configuration
|
213
|
+
config.auto_setup_reverse_associations = false # Default: true
|
214
|
+
config.reverse_association_prefix = "track_" # Default: ""
|
215
|
+
config.reverse_association_suffix = "_logs" # Default: ""
|
216
|
+
|
158
217
|
# Column data type configuration
|
159
218
|
config.column_data_type = :integer # Default: :bigint (applies to all columns)
|
160
219
|
config.creator_column_type = :string # Default: nil (uses column_data_type)
|
@@ -344,7 +403,7 @@ end
|
|
344
403
|
| Feature | Whodunit | PaperTrail | Audited |
|
345
404
|
| --------------------- | -------- | ---------- | ------- |
|
346
405
|
| User tracking | ✅ | ✅ | ✅ |
|
347
|
-
| Change history |
|
406
|
+
| Change history | Via [whodunit-chronicles](https://github.com/kanutocd/whodunit-chronicles) | ✅ | ✅ |
|
348
407
|
| Performance overhead | None | High | Medium |
|
349
408
|
| Soft-delete detection | ✅ | ❌ | ❌ |
|
350
409
|
| Setup complexity | Low | Medium | Medium |
|
data/lib/whodunit/stampable.rb
CHANGED
@@ -52,6 +52,10 @@ module Whodunit
|
|
52
52
|
|
53
53
|
# Set up associations - call on the class
|
54
54
|
setup_whodunit_associations
|
55
|
+
|
56
|
+
# Register this model for reverse association setup
|
57
|
+
# This happens immediately, but the check for enabled status is done in register_model
|
58
|
+
Whodunit.register_model(self)
|
55
59
|
end
|
56
60
|
|
57
61
|
class_methods do # rubocop:disable Metrics/BlockLength
|
@@ -82,6 +86,31 @@ module Whodunit
|
|
82
86
|
@soft_delete_enabled = false
|
83
87
|
end
|
84
88
|
|
89
|
+
# Disable reverse association setup for this model
|
90
|
+
# Call this method in the model class to prevent automatic reverse associations
|
91
|
+
# @example
|
92
|
+
# class Post < ApplicationRecord
|
93
|
+
# include Whodunit::Stampable
|
94
|
+
# disable_whodunit_reverse_associations!
|
95
|
+
# end
|
96
|
+
def disable_whodunit_reverse_associations!
|
97
|
+
@whodunit_reverse_associations_disabled = true
|
98
|
+
# Remove from registered models if already registered
|
99
|
+
Whodunit.registered_models.delete(self)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Check if reverse associations are enabled for this model
|
103
|
+
# @return [Boolean] true if reverse associations should be set up
|
104
|
+
def whodunit_reverse_associations_enabled?
|
105
|
+
!@whodunit_reverse_associations_disabled
|
106
|
+
end
|
107
|
+
|
108
|
+
# Manually set up reverse associations for this model
|
109
|
+
# Can be called if reverse associations were disabled but you want to set them up later
|
110
|
+
def setup_whodunit_reverse_associations!
|
111
|
+
Whodunit.setup_reverse_associations_for_model(self) if whodunit_reverse_associations_enabled?
|
112
|
+
end
|
113
|
+
|
85
114
|
# Get effective configuration value (model override or global default)
|
86
115
|
def whodunit_setting(key)
|
87
116
|
return @whodunit_config_overrides[key] if @whodunit_config_overrides&.key?(key)
|
data/lib/whodunit/version.rb
CHANGED
data/lib/whodunit.rb
CHANGED
@@ -83,6 +83,31 @@ module Whodunit
|
|
83
83
|
# @return [Symbol, nil] the deleter column data type
|
84
84
|
mattr_accessor :deleter_column_type, default: nil
|
85
85
|
|
86
|
+
# @!group Reverse Association Configuration
|
87
|
+
|
88
|
+
# Whether to automatically set up reverse associations on the user class (default: true)
|
89
|
+
# When enabled, including Whodunit::Stampable in a model will automatically add
|
90
|
+
# has_many associations to the user class (e.g., has_many :created_posts)
|
91
|
+
# @return [Boolean] auto reverse association setting
|
92
|
+
mattr_accessor :auto_setup_reverse_associations, default: true
|
93
|
+
|
94
|
+
# Prefix for reverse association names (default: "")
|
95
|
+
# Used to generate association names like "created_posts", "updated_comments"
|
96
|
+
# @return [String] the prefix for reverse association names
|
97
|
+
mattr_accessor :reverse_association_prefix, default: ""
|
98
|
+
|
99
|
+
# Suffix for reverse association names (default: "")
|
100
|
+
# Used to generate association names like "posts_created", "comments_updated"
|
101
|
+
# @return [String] the suffix for reverse association names
|
102
|
+
mattr_accessor :reverse_association_suffix, default: ""
|
103
|
+
|
104
|
+
# @!group Model Registry
|
105
|
+
|
106
|
+
# Registry to track models that include Whodunit::Stampable
|
107
|
+
# This is used to set up reverse associations on the user class
|
108
|
+
# @return [Array<Class>] array of model classes that include Stampable
|
109
|
+
mattr_accessor :registered_models, default: []
|
110
|
+
|
86
111
|
# Configure Whodunit settings
|
87
112
|
#
|
88
113
|
# @example
|
@@ -151,6 +176,135 @@ module Whodunit
|
|
151
176
|
!deleter_column.nil?
|
152
177
|
end
|
153
178
|
|
179
|
+
# @!group Model Registration & Reverse Associations
|
180
|
+
|
181
|
+
# Register a model class that includes Whodunit::Stampable
|
182
|
+
# This is called automatically when Stampable is included
|
183
|
+
# @param [Class] model_class the model class to register
|
184
|
+
# @return [void]
|
185
|
+
def self.register_model(model_class)
|
186
|
+
return unless auto_setup_reverse_associations
|
187
|
+
return if registered_models.include?(model_class)
|
188
|
+
return if model_class.respond_to?(:whodunit_reverse_associations_enabled?) &&
|
189
|
+
!model_class.whodunit_reverse_associations_enabled?
|
190
|
+
|
191
|
+
registered_models << model_class
|
192
|
+
setup_reverse_associations_for_model(model_class)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Set up reverse associations on the user class for a specific model
|
196
|
+
# @param [Class] model_class the model class to set up reverse associations for
|
197
|
+
# @return [void]
|
198
|
+
def self.setup_reverse_associations_for_model(model_class)
|
199
|
+
return unless auto_setup_reverse_associations
|
200
|
+
|
201
|
+
user_class_instance = resolve_user_class
|
202
|
+
return unless user_class_instance.respond_to?(:has_many)
|
203
|
+
|
204
|
+
model_plural = model_class.name.underscore.pluralize
|
205
|
+
|
206
|
+
setup_creator_reverse_association(user_class_instance, model_class, model_plural)
|
207
|
+
setup_updater_reverse_association(user_class_instance, model_class, model_plural)
|
208
|
+
setup_deleter_reverse_association(user_class_instance, model_class, model_plural)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Set up all reverse associations for all registered models
|
212
|
+
# This can be called manually if needed (e.g., after configuration changes)
|
213
|
+
# @return [void]
|
214
|
+
def self.setup_all_reverse_associations
|
215
|
+
registered_models.each do |model_class|
|
216
|
+
setup_reverse_associations_for_model(model_class)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Generate a reverse association name based on action and model name
|
221
|
+
# @param [String] action the action (created, updated, deleted)
|
222
|
+
# @param [String] model_plural the pluralized model name
|
223
|
+
# @return [String] the generated association name
|
224
|
+
def self.generate_reverse_association_name(action, model_plural)
|
225
|
+
"#{reverse_association_prefix}#{action}_#{model_plural}#{reverse_association_suffix}"
|
226
|
+
end
|
227
|
+
|
228
|
+
# Resolve the user class constant
|
229
|
+
# @return [Class, nil] the user class or nil if not found
|
230
|
+
def self.resolve_user_class
|
231
|
+
user_class.constantize
|
232
|
+
rescue StandardError
|
233
|
+
nil
|
234
|
+
end
|
235
|
+
|
236
|
+
# Set up creator reverse association
|
237
|
+
# @param [Class] user_class_instance the user class
|
238
|
+
# @param [Class] model_class the model class
|
239
|
+
# @param [String] model_plural the pluralized model name
|
240
|
+
# @return [void]
|
241
|
+
def self.setup_creator_reverse_association(user_class_instance, model_class, model_plural)
|
242
|
+
return unless model_class.respond_to?(:model_creator_enabled?) && model_class.model_creator_enabled?
|
243
|
+
|
244
|
+
association_name = generate_reverse_association_name("created", model_plural)
|
245
|
+
setup_user_reverse_association(user_class_instance, association_name, model_class, :creator_id)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Set up updater reverse association
|
249
|
+
# @param [Class] user_class_instance the user class
|
250
|
+
# @param [Class] model_class the model class
|
251
|
+
# @param [String] model_plural the pluralized model name
|
252
|
+
# @return [void]
|
253
|
+
def self.setup_updater_reverse_association(user_class_instance, model_class, model_plural)
|
254
|
+
return unless model_class.respond_to?(:model_updater_enabled?) && model_class.model_updater_enabled?
|
255
|
+
|
256
|
+
association_name = generate_reverse_association_name("updated", model_plural)
|
257
|
+
setup_user_reverse_association(user_class_instance, association_name, model_class, :updater_id)
|
258
|
+
end
|
259
|
+
|
260
|
+
# Set up deleter reverse association
|
261
|
+
# @param [Class] user_class_instance the user class
|
262
|
+
# @param [Class] model_class the model class
|
263
|
+
# @param [String] model_plural the pluralized model name
|
264
|
+
# @return [void]
|
265
|
+
def self.setup_deleter_reverse_association(user_class_instance, model_class, model_plural)
|
266
|
+
return unless model_class.respond_to?(:model_deleter_enabled?) && model_class.model_deleter_enabled?
|
267
|
+
return unless model_class.respond_to?(:soft_delete_enabled?) && model_class.soft_delete_enabled?
|
268
|
+
|
269
|
+
association_name = generate_reverse_association_name("deleted", model_plural)
|
270
|
+
setup_user_reverse_association(user_class_instance, association_name, model_class, :deleter_id)
|
271
|
+
end
|
272
|
+
|
273
|
+
# Set up a specific reverse association on the user class
|
274
|
+
# @param [Class] user_class_instance the user class
|
275
|
+
# @param [String] association_name the name of the association
|
276
|
+
# @param [Class] model_class the model class
|
277
|
+
# @param [Symbol] foreign_key_column the foreign key column name
|
278
|
+
# @return [void]
|
279
|
+
def self.setup_user_reverse_association(user_class_instance, association_name, model_class, foreign_key_column)
|
280
|
+
actual_foreign_key = resolve_foreign_key(model_class, foreign_key_column)
|
281
|
+
|
282
|
+
# Check if association already exists to avoid duplicates
|
283
|
+
return if user_class_instance.reflect_on_association(association_name.to_sym)
|
284
|
+
|
285
|
+
user_class_instance.has_many association_name.to_sym,
|
286
|
+
class_name: model_class.name,
|
287
|
+
foreign_key: actual_foreign_key,
|
288
|
+
dependent: :nullify
|
289
|
+
end
|
290
|
+
|
291
|
+
# Resolve the actual foreign key column name from model configuration
|
292
|
+
# @param [Class] model_class the model class
|
293
|
+
# @param [Symbol] foreign_key_column the default foreign key column
|
294
|
+
# @return [Symbol] the actual foreign key column name
|
295
|
+
def self.resolve_foreign_key(model_class, foreign_key_column)
|
296
|
+
return foreign_key_column unless model_class.respond_to?(:whodunit_setting)
|
297
|
+
|
298
|
+
column_mapping = {
|
299
|
+
creator_id: :creator_column,
|
300
|
+
updater_id: :updater_column,
|
301
|
+
deleter_id: :deleter_column
|
302
|
+
}
|
303
|
+
|
304
|
+
setting_key = column_mapping[foreign_key_column]
|
305
|
+
setting_key ? (model_class.whodunit_setting(setting_key) || foreign_key_column) : foreign_key_column
|
306
|
+
end
|
307
|
+
|
154
308
|
# Validate that column configuration is valid
|
155
309
|
# @raise [Whodunit::Error] if both creator_column and updater_column are nil
|
156
310
|
def self.validate_column_configuration!
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: whodunit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ken C. Demanawa
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-07-
|
11
|
+
date: 2025-07-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|