model_timeline 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.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelTimeline
4
+ module RSpec
5
+ # rubocop:disable Naming/PredicateName
6
+ # Custom RSpec matchers for testing model timeline entries
7
+ #
8
+ # These matchers help you test the timeline entries created by the ModelTimeline gem.
9
+ # Use them in your specs to verify that your models are correctly recording timeline events.
10
+ #
11
+ # @example Basic usage with different matchers
12
+ # # Check for any timeline entries
13
+ # expect(user).to have_timeline_entries
14
+ #
15
+ # # Check for specific number of entries
16
+ # expect(user).to have_timeline_entries(3)
17
+ #
18
+ # # Check for specific action
19
+ # expect(user).to have_timelined_action(:update)
20
+ #
21
+ # # Check for changes to a specific attribute
22
+ # expect(user).to have_timelined_change(:email)
23
+ #
24
+ # # Check for specific value change
25
+ # expect(user).to have_timelined_entry(:status, "active")
26
+ module Matchers
27
+ # Check if a model has timeline entries
28
+ #
29
+ # @param count [Integer, nil] The expected number of timeline entries (optional)
30
+ # @return [HaveTimelineEntriesMatcher] A matcher that checks for timeline entries
31
+ # @example Without count parameter
32
+ # expect(user).to have_timeline_entries
33
+ # @example With count parameter
34
+ # expect(user).to have_timeline_entries(3)
35
+ def have_timeline_entries(count = nil)
36
+ HaveTimelineEntriesMatcher.new(count)
37
+ end
38
+
39
+ # Check if a specific action was recorded in the timeline
40
+ #
41
+ # @param action [String, Symbol] The action name to look for
42
+ # @return [HaveTimelinedAction] A matcher that checks for a specific action
43
+ # @example
44
+ # expect(user).to have_timelined_action(:create)
45
+ # expect(user).to have_timelined_action("update")
46
+ def have_timelined_action(action)
47
+ HaveTimelinedAction.new(action)
48
+ end
49
+
50
+ # Check if a specific attribute change was recorded in the timeline
51
+ #
52
+ # @param attribute [String, Symbol] The attribute name to check for changes
53
+ # @return [HaveTimelinedChange] A matcher that checks if an attribute was changed
54
+ # @example
55
+ # expect(user).to have_timelined_change(:email)
56
+ # expect(user).to have_timelined_change("status")
57
+ def have_timelined_change(attribute)
58
+ HaveTimelinedChange.new(attribute)
59
+ end
60
+
61
+ # Check if an attribute was changed to a specific value in the timeline
62
+ #
63
+ # @param attribute [String, Symbol] The attribute name to check
64
+ # @param value [Object] The value to check for
65
+ # @return [HaveTimelinedEntry] A matcher that checks for specific attribute values
66
+ # @example
67
+ # expect(user).to have_timelined_entry(:status, "active")
68
+ # expect(user).to have_timelined_entry(:role, :admin)
69
+ def have_timelined_entry(attribute, value)
70
+ HaveTimelinedEntry.new(attribute, value)
71
+ end
72
+
73
+ # RSpec matcher to check if a model has timeline entries
74
+ #
75
+ # @api private
76
+ class HaveTimelineEntriesMatcher
77
+ # Initialize the matcher
78
+ #
79
+ # @param expected_count [Integer, nil] The expected number of timeline entries
80
+ def initialize(expected_count)
81
+ @expected_count = expected_count
82
+ end
83
+
84
+ # Check if the subject matches the expectations
85
+ #
86
+ # @param subject [Object] The model to check for timeline entries
87
+ # @return [Boolean] True if the model has the expected number of timeline entries
88
+ def matches?(subject)
89
+ @subject = subject
90
+ if @expected_count.nil?
91
+ subject.timeline_entries.any?
92
+ else
93
+ subject.timeline_entries.count == @expected_count
94
+ end
95
+ end
96
+
97
+ # Message displayed when the expectation fails
98
+ #
99
+ # @return [String] A descriptive failure message
100
+ def failure_message
101
+ if @expected_count.nil?
102
+ "expected #{@subject} to have timeline entries, but found none"
103
+ else
104
+ "expected #{@subject} to have #{@expected_count} timeline entries, " \
105
+ "but found #{@subject.timeline_entries.count}"
106
+ end
107
+ end
108
+
109
+ # Message displayed when the negated expectation fails
110
+ #
111
+ # @return [String] A descriptive failure message for negated expectations
112
+ def failure_message_when_negated
113
+ if @expected_count.nil?
114
+ "expected #{@subject} not to have any timeline entries, but found #{@subject.timeline_entries.count}"
115
+ else
116
+ "expected #{@subject} not to have #{@expected_count} timeline entries, but found exactly that many"
117
+ end
118
+ end
119
+ end
120
+
121
+ # RSpec matcher to check if a model has timeline entries with a specific action
122
+ #
123
+ # @api private
124
+ class HaveTimelinedAction
125
+ # Initialize the matcher
126
+ #
127
+ # @param action [String, Symbol] The action to look for
128
+ def initialize(action)
129
+ @action = action.to_s
130
+ end
131
+
132
+ # Check if the subject matches the expectations
133
+ #
134
+ # @param subject [Object] The model to check for timeline entries
135
+ # @return [Boolean] True if the model has timeline entries with the specified action
136
+ def matches?(subject)
137
+ @subject = subject
138
+ subject.timeline_entries.where(action: @action).exists?
139
+ end
140
+
141
+ # Message displayed when the expectation fails
142
+ #
143
+ # @return [String] A descriptive failure message
144
+ def failure_message
145
+ "expected #{@subject} to have recorded action '#{@action}', but none was found"
146
+ end
147
+
148
+ # Message displayed when the negated expectation fails
149
+ #
150
+ # @return [String] A descriptive failure message for negated expectations
151
+ def failure_message_when_negated
152
+ "expected #{@subject} not to have recorded action '#{@action}', but it was found"
153
+ end
154
+ end
155
+
156
+ # RSpec matcher to check if a model has timeline entries with changes to a specific attribute
157
+ #
158
+ # @api private
159
+ class HaveTimelinedChange
160
+ # Initialize the matcher
161
+ #
162
+ # @param attribute [String, Symbol] The attribute to check for changes
163
+ def initialize(attribute)
164
+ @attribute = attribute.to_s
165
+ end
166
+
167
+ # Check if the subject matches the expectations
168
+ #
169
+ # @param subject [Object] The model to check for timeline entries
170
+ # @return [Boolean] True if the model has timeline entries with changes to the specified attribute
171
+ def matches?(subject)
172
+ @subject = subject
173
+ subject.timeline_entries.with_changed_attribute(@attribute).exists?
174
+ end
175
+
176
+ # Message displayed when the expectation fails
177
+ #
178
+ # @return [String] A descriptive failure message
179
+ def failure_message
180
+ "expected #{@subject} to have tracked changes to '#{@attribute}', but none was found"
181
+ end
182
+
183
+ # Message displayed when the negated expectation fails
184
+ #
185
+ # @return [String] A descriptive failure message for negated expectations
186
+ def failure_message_when_negated
187
+ "expected #{@subject} not to have tracked changes to '#{@attribute}', but changes were found"
188
+ end
189
+ end
190
+
191
+ # RSpec matcher to check if a model has timeline entries where an attribute changed to a specific value
192
+ #
193
+ # @api private
194
+ class HaveTimelinedEntry
195
+ # Initialize the matcher
196
+ #
197
+ # @param attribute [String, Symbol] The attribute to check
198
+ # @param value [Object] The value the attribute should have changed to
199
+ def initialize(attribute, value)
200
+ @attribute = attribute.to_s
201
+ @value = value
202
+ end
203
+
204
+ # Check if the subject matches the expectations
205
+ #
206
+ # @param subject [Object] The model to check for timeline entries
207
+ # @return [Boolean] True if the model has timeline entries where the attribute changed to the specified value
208
+ def matches?(subject)
209
+ @subject = subject
210
+ subject.timeline_entries.with_changed_value(@attribute, @value).exists?
211
+ end
212
+
213
+ # Message displayed when the expectation fails
214
+ #
215
+ # @return [String] A descriptive failure message
216
+ def failure_message
217
+ "expected #{@subject} to have tracked '#{@attribute}' changing to '#{@value}', but no such change was found"
218
+ end
219
+
220
+ # Message displayed when the negated expectation fails
221
+ #
222
+ # @return [String] A descriptive failure message for negated expectations
223
+ def failure_message_when_negated
224
+ "expected #{@subject} not to have tracked '#{@attribute}' changing to '#{@value}', but such a change was found"
225
+ end
226
+ end
227
+ end
228
+ # rubocop:enable Naming/PredicateName
229
+ end
230
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'model_timeline/rspec/matchers'
4
+ module ModelTimeline
5
+ # Helper module that configures RSpec to work with ModelTimeline
6
+ #
7
+ # This module provides RSpec configuration for ModelTimeline, including:
8
+ # - Disabling timeline recording by default for faster tests
9
+ # - Enabling timeline recording only when specifically requested
10
+ # - Including custom RSpec matchers for testing timeline entries
11
+ #
12
+ # @example Including in RSpec configuration
13
+ # # In spec_helper.rb or rails_helper.rb
14
+ # RSpec.configure do |config|
15
+ # config.include ModelTimelineHelper
16
+ # end
17
+ #
18
+ # @example Running a test with timeline recording enabled
19
+ # # Use the :with_timeline metadata to enable recording
20
+ # describe User, :with_timeline do
21
+ # it "records timeline entries when updated" do
22
+ # user.update(name: "New Name")
23
+ # expect(user).to have_timelined_change(:name)
24
+ # end
25
+ # end
26
+ module RSpec
27
+ # Configures RSpec with ModelTimeline hooks when included
28
+ #
29
+ # @param config [RSpec::Core::Configuration] The RSpec configuration object
30
+ # @return [void]
31
+ def self.included(config)
32
+ # Reset timeline state before each example
33
+ config.before(:each) do |example|
34
+ ModelTimeline.disable! unless example.metadata[:with_timeline]
35
+ end
36
+
37
+ # Enable timeline when the :with_timeline metadata is present
38
+ config.around(:each, :with_timeline) do |example|
39
+ ModelTimeline.enable!
40
+ example.run
41
+ ensure
42
+ ModelTimeline.disable!
43
+ end
44
+
45
+ # Include custom RSpec matchers for testing timeline entries
46
+ config.include ModelTimeline::RSpec::Matchers
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelTimeline
4
+ # Represents a timeline entry that records changes to a model.
5
+ # TimelineEntry stores the tracked object, user who made the change,
6
+ # IP address, and the changes that were made.
7
+ #
8
+ class TimelineEntry < ActiveRecord::Base
9
+ self.table_name = 'model_timeline_timeline_entries'
10
+
11
+ # @!attribute timelineable
12
+ # @return [Object] The model instance that this timeline entry belongs to
13
+ belongs_to :timelineable, polymorphic: true, optional: true
14
+
15
+ # @!attribute user
16
+ # @return [Object] The user who made the change
17
+ belongs_to :user, polymorphic: true, optional: true
18
+
19
+ # Retrieves timeline entries for a specific timelineable object
20
+ #
21
+ # @param [Object] timelineable The object to find timeline entries for
22
+ # @return [ActiveRecord::Relation] Timeline entries for the specified object
23
+ def self.for_timelineable(timelineable)
24
+ where(timelineable_type: timelineable.class.name, timelineable_id: timelineable.id)
25
+ end
26
+
27
+ # Retrieves timeline entries created by a specific user
28
+ #
29
+ # @param [Object] user The user who created the timeline entries
30
+ # @return [ActiveRecord::Relation] Timeline entries created by the specified user
31
+ def self.for_user(user)
32
+ where(user_type: user.class.name, user_id: user.id)
33
+ end
34
+
35
+ # Retrieves timeline entries from a specific IP address
36
+ #
37
+ # @param [String] ip The IP address to search for
38
+ # @return [ActiveRecord::Relation] Timeline entries from the specified IP address
39
+ def self.for_ip_address(ip)
40
+ where(ip_address: ip)
41
+ end
42
+
43
+ # Retrieves timeline entries where a specific attribute was changed
44
+ #
45
+ # @param [Symbol, String] attribute The attribute name to check for changes
46
+ # @return [ActiveRecord::Relation] Timeline entries where the specified attribute changed
47
+ def self.with_changed_attribute(attribute)
48
+ where('object_changes ? :key', key: attribute.to_s)
49
+ end
50
+
51
+ # Retrieves timeline entries where an attribute was changed to a specific value
52
+ #
53
+ # @param [Symbol, String] attribute The attribute name to check
54
+ # @param [Object] value The value the attribute was changed to
55
+ # @return [ActiveRecord::Relation] Timeline entries matching the attribute and value change
56
+ def self.with_changed_value(attribute, value)
57
+ where('object_changes -> :key ->> 1 = :value', key: attribute.to_s, value: value.to_s)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelTimeline
4
+ # Provides timeline tracking functionality for ActiveRecord models.
5
+ # When included in a model, this module allows tracking changes to model attributes
6
+ # over time by creating timeline entries.
7
+ #
8
+ # @example Basic usage
9
+ # class User < ApplicationRecord
10
+ # has_timeline
11
+ # end
12
+ #
13
+ # @example With custom options
14
+ # class Product < ApplicationRecord
15
+ # has_timeline :product_history,
16
+ # only: [:name, :price],
17
+ # on: [:update, :destroy]
18
+ # end
19
+ #
20
+ module Timelineable
21
+ extend ActiveSupport::Concern
22
+
23
+ included do
24
+ class_attribute :loggers, default: {}
25
+ end
26
+
27
+ # Methods that will be added as class methods to the including class
28
+ class_methods do
29
+ # Enables timeline tracking for the model.
30
+ # This method configures the model to record timeline entries on various
31
+ # lifecycle events (create, update, destroy).
32
+ #
33
+ # @param association_name [Symbol] The name for the timeline entries association
34
+ # @param options [Hash] Configuration options
35
+ # @option options [Array<Symbol>] :on ([:create, :update, :destroy]) Which events to track
36
+ # @option options [Array<Symbol>] :only Limit tracking to these attributes only
37
+ # @option options [Array<Symbol>] :ignore ([]) Attributes to exclude from tracking
38
+ # @option options [String] :class_name ('ModelTimeline::TimelineEntry') The timeline entry class
39
+ # @option options [Hash] :meta ({}) Additional metadata to include with each entry
40
+ # @return [void]
41
+ # @raise [ModelTimeline::ConfigurationError] If timeline has already been configured
42
+ #
43
+ # @example Track all changes
44
+ # has_timeline
45
+ #
46
+ # @example Track only specific attributes
47
+ # has_timeline(only: [:name, :status])
48
+ #
49
+ # @example Use a custom association name
50
+ # has_timeline(:audit_log)
51
+ #
52
+ # @example Add metadata to entries
53
+ # has_timeline(meta: { app_version: APP_VERSION })
54
+ #
55
+ # rubocop:disable Metrics/CyclomaticComplexity
56
+ # rubocop:disable Metrics/PerceivedComplexity
57
+ # rubocop:disable Naming/PredicateName
58
+ def has_timeline(*args, **kwargs)
59
+ association_name = args.first.is_a?(Symbol) ? args.shift : :timeline_entries
60
+
61
+ klass = (kwargs[:class_name] || 'ModelTimeline::TimelineEntry').constantize
62
+
63
+ config = {
64
+ on: kwargs[:on] || %i[create update destroy],
65
+ only: kwargs[:only],
66
+ ignore: kwargs[:ignore] || [],
67
+ klass: klass,
68
+ meta: kwargs[:meta] || {}
69
+ }
70
+
71
+ config_key = "#{to_s.underscore}-#{klass}"
72
+ raise ::ModelTimeline::ConfigurationError if loggers[config_key].present?
73
+
74
+ loggers[config_key] = config
75
+
76
+ after_save -> { log_after_save(config_key) } if config[:on].include?(:create) || config[:on].include?(:update)
77
+
78
+ after_destroy -> { log_audit_deletion(config_key) } if config[:on].include?(:destroy)
79
+
80
+ has_many association_name.to_sym, class_name: klass.name, as: :timelineable
81
+ end
82
+ # rubocop:enable Metrics/CyclomaticComplexity
83
+ # rubocop:enable Metrics/PerceivedComplexity
84
+ # rubocop:enable Naming/PredicateName
85
+ end
86
+
87
+ private
88
+
89
+ # Records timeline entries after a model is saved (create or update)
90
+ #
91
+ # @param config_key [String] The configuration key for this model
92
+ # @return [void]
93
+ def log_after_save(config_key)
94
+ return unless ModelTimeline.enabled?
95
+
96
+ config = self.class.loggers[config_key]
97
+ return unless config
98
+
99
+ object_changes = filter_attributes(previous_changes, config)
100
+ return if object_changes.empty?
101
+
102
+ action = previously_new_record? ? :create : :update
103
+ return unless config[:on].include?(action)
104
+
105
+ config[:klass].create!(
106
+ timelineable_type: self.class.name,
107
+ timelineable_id: id,
108
+ action: action,
109
+ object_changes: object_changes,
110
+ ip_address: current_ip_address,
111
+ **current_user_attributes,
112
+ **collect_metadata(config[:meta], config_key)
113
+ )
114
+ end
115
+
116
+ # Records timeline entries after a model is destroyed
117
+ #
118
+ # @param config_key [String] The configuration key for this model
119
+ # @return [void]
120
+ def log_audit_deletion(config_key)
121
+ return unless ModelTimeline.enabled?
122
+
123
+ config = self.class.loggers[config_key]
124
+ return unless config
125
+
126
+ config[:klass].create!(
127
+ timelineable_type: self.class.name,
128
+ timelineable_id: id,
129
+ action: 'destroy',
130
+ object_changes: {},
131
+ ip_address: current_ip_address,
132
+ **current_user_attributes,
133
+ **collect_metadata(config[:meta], config_key)
134
+ )
135
+ end
136
+
137
+ # Gets the current user ID if available
138
+ #
139
+ # @return [Integer, nil] The current user ID or nil if not available
140
+ def current_user_id
141
+ return unless respond_to?(ModelTimeline.current_user_method)
142
+
143
+ user = send(ModelTimeline.current_user_method)
144
+ user.respond_to?(:id) ? user.id : nil
145
+ end
146
+
147
+ # Gets the current IP address from the request store
148
+ #
149
+ # @return [String, nil] The current IP address or nil if not available
150
+ def current_ip_address
151
+ ModelTimeline.current_ip
152
+ end
153
+
154
+ # Prepares user attributes for the timeline entry
155
+ #
156
+ # @return [Hash] User attributes for the timeline entry
157
+ def current_user_attributes
158
+ user = ModelTimeline.current_user
159
+ return {} if user.nil?
160
+
161
+ return { username: user.to_s } if user.is_a?(String) || user.is_a?(Symbol)
162
+
163
+ return { user_id: user.id, user_type: user.class.name } if user.respond_to?(:id) &&
164
+ user.class.ancestors.include?(ActiveRecord::Base)
165
+
166
+ return { username: user.to_s } if user.respond_to?(:to_s)
167
+
168
+ {}
169
+ end
170
+
171
+ # Filters attributes based on configuration
172
+ #
173
+ # @param attrs [Hash] The attributes to filter
174
+ # @param config [Hash] The timeline configuration
175
+ # @return [Hash] Filtered attributes
176
+ def filter_attributes(attrs, config)
177
+ result = attrs.dup.with_indifferent_access
178
+ result = result.slice(*config[:only]) if config[:only].present?
179
+ result = result.except(*config[:ignore]) if config[:ignore].present?
180
+
181
+ result
182
+ end
183
+
184
+ # Collects metadata for the timeline entry
185
+ #
186
+ # @param meta_config [Hash] The metadata configuration
187
+ # @param config_key [String] The configuration key for this model
188
+ # @return [Hash] Collected metadata
189
+ def collect_metadata(meta_config, config_key)
190
+ config = self.class.loggers[config_key]
191
+ metadata = {}
192
+
193
+ # First, add any thread-level metadata
194
+ metadata.merge!(ModelTimeline.metadata)
195
+
196
+ # Then, add any model-specific metadata defined in config
197
+ meta_config.each do |key, value|
198
+ resolved_value = case value
199
+ when Proc
200
+ instance_exec(self, &value)
201
+ when Symbol
202
+ respond_to?(value) ? send(value) : value
203
+ else
204
+ value
205
+ end
206
+ metadata[key] = resolved_value
207
+ end
208
+
209
+ # Only include keys that exist as columns in the timeline entry table
210
+ column_names = config[:klass].column_names.map(&:to_sym)
211
+ metadata.slice(*column_names)
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelTimeline
4
+ # Current version of the ModelTimeline gem.
5
+ # Follows semantic versioning (https://semver.org/).
6
+ #
7
+ # @return [String] The current version in the format "MAJOR.MINOR.PATCH"
8
+ VERSION = '0.1.0'
9
+ end