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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ce926c88b69914bd703491e1bf11f890f64a4c4f5679929d1b898306ea18fcd
4
- data.tar.gz: 3b277cf8bbf1f36e494f90f959941c38b3829f541b5b36b1168b98a276d830f7
3
+ metadata.gz: 323bc72651528d7bf5e205c0388462078acc718ea39b14b5775517644a2115fc
4
+ data.tar.gz: d20344c823d5716ae218497ca92f61f41c6660c8e8f27a5479b0e52bc64b6b25
5
5
  SHA512:
6
- metadata.gz: 68b739ee97b145af1451991f589dfd9353da7ff579592e9f45071c532ed095e2b83e166b30b987bdbd6a22f1f47b421d1dc76fce7818491889376a0249ecdd21
7
- data.tar.gz: 3796e3c88a21640063c504d27f3fa34a16f077eac5653826cd7a0a4ad513288dec0aa88383afa633daac57d25719711e2325cc33e01079f00725f76d2d22270d
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
+ [![Gem Version](https://badge.fury.io/rb/whodunit.svg)](https://badge.fury.io/rb/whodunit)
4
+ [![CI](https://github.com/kanutocd/whodunit/workflows/CI/badge.svg)](https://github.com/kanutocd/whodunit/actions)
5
+ [![Coverage Status](https://codecov.io/gh/kanutocd/whodunit/branch/main/graph/badge.svg)](https://codecov.io/gh/kanutocd/whodunit)
6
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 |
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whodunit
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.1
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-21 00:00:00.000000000 Z
11
+ date: 2025-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord