model_timeline 0.1.0 → 0.1.2
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/README.md +68 -21
- data/lib/model_timeline/generators/templates/migration.rb.tt +2 -0
- data/lib/model_timeline/rspec/matchers.rb +127 -59
- data/lib/model_timeline/rspec.rb +1 -1
- data/lib/model_timeline/timelineable.rb +52 -18
- data/lib/model_timeline/version.rb +1 -1
- 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: 9d1c5aacdbe6970e7724f4f40d81c2e67b0f7739b7efe4784adb773a88d142a4
|
|
4
|
+
data.tar.gz: 38fc7b5502adf1a18809f8fd7aa54d3f5906888a9e37ed3f6b8fa9ef232e893c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5710fbe04cd8871236b90891e2298098b4d986b9d63e4f0c5943af5592b0e4ee47bf248490f2f508992a3a39ea50f20839f11d5b043196cbb8e6a527925d6d1c
|
|
7
|
+
data.tar.gz: 584e6aeb8875bffd927d86532e0b05cb2989027df80b6b5cf0ed15d587bfdce1f65f9f5cb6a92b8a88dc7e416ac56c653604d6f9635a6dc91a53ab4f97046192
|
data/README.md
CHANGED
|
@@ -127,7 +127,6 @@ end
|
|
|
127
127
|
|
|
128
128
|
### Using Metadata
|
|
129
129
|
|
|
130
|
-
<!-- ! THIS IS WRONG, NEEDS TO BE UPDATED. -->
|
|
131
130
|
ModelTimeline allows you to include custom metadata with your timeline entries, which is especially useful for tracking changes across related entities or adding domain-specific context.
|
|
132
131
|
|
|
133
132
|
#### Adding Metadata Through Configuration
|
|
@@ -147,8 +146,7 @@ end
|
|
|
147
146
|
```
|
|
148
147
|
|
|
149
148
|
If your timeline table has columns that match the keys in your `meta` hash, these values will be stored in
|
|
150
|
-
those dedicated columns. Otherwise they will be
|
|
151
|
-
added to put additional metadata that doesn't have a dedicated column.)
|
|
149
|
+
those dedicated columns. Otherwise, they will be stored inside `metadata` column.
|
|
152
150
|
|
|
153
151
|
#### Adding Metadata at Runtime
|
|
154
152
|
|
|
@@ -172,13 +170,16 @@ For tracking related entities more effectively, you can create a custom timeline
|
|
|
172
170
|
class CreatePostTimelineEntries < ActiveRecord::Migration[6.1]
|
|
173
171
|
def change
|
|
174
172
|
create_table :post_timeline_entries do |t|
|
|
175
|
-
#
|
|
176
|
-
t.string
|
|
177
|
-
t.
|
|
178
|
-
t.string
|
|
179
|
-
t.jsonb
|
|
180
|
-
t.
|
|
181
|
-
t.string
|
|
173
|
+
# Default Columns - All of them are required.
|
|
174
|
+
t.string :timelineable_type
|
|
175
|
+
t.bigint :timelineable_id
|
|
176
|
+
t.string :action, null: false
|
|
177
|
+
t.jsonb :object_changes, default: {}, null: false
|
|
178
|
+
t.jsonb :metadata, default: {}, null: false
|
|
179
|
+
t.string :user_type
|
|
180
|
+
t.bigint :user_id
|
|
181
|
+
t.string :username
|
|
182
|
+
t.inet :ip_address
|
|
182
183
|
|
|
183
184
|
# Custom columns that can be populated via the meta option
|
|
184
185
|
t.integer :post_id
|
|
@@ -186,9 +187,12 @@ class CreatePostTimelineEntries < ActiveRecord::Migration[6.1]
|
|
|
186
187
|
t.timestamps
|
|
187
188
|
end
|
|
188
189
|
|
|
189
|
-
add_index :post_timeline_entries, [:timelineable_type, :timelineable_id]
|
|
190
|
-
add_index :post_timeline_entries, :
|
|
191
|
-
add_index :post_timeline_entries, :
|
|
190
|
+
add_index :post_timeline_entries, [:timelineable_type, :timelineable_id], name: 'idx_timeline_on_timelineable'
|
|
191
|
+
add_index :post_timeline_entries, [:user_type, :user_id], name: 'idx_timeline_on_user'
|
|
192
|
+
add_index :post_timeline_entries, :object_changes, using: :gin, name: 'idx_timeline_on_changes'
|
|
193
|
+
add_index :post_timeline_entries, :metadata, using: :gin, name: 'idx_timeline_on_meta'
|
|
194
|
+
add_index :post_timeline_entries, :ip_address, name: 'idx_timeline_on_ip'
|
|
195
|
+
add_index :post_timeline_entries, :post_id, name: 'idx_timeline_on_post_id'
|
|
192
196
|
end
|
|
193
197
|
end
|
|
194
198
|
```
|
|
@@ -381,11 +385,6 @@ Configure RSpec to work with ModelTimeline:
|
|
|
381
385
|
require 'model_timeline/rspec'
|
|
382
386
|
|
|
383
387
|
RSpec.configure do |config|
|
|
384
|
-
# This disables ModelTimeline by default in tests for better performance
|
|
385
|
-
config.before(:suite) do
|
|
386
|
-
ModelTimeline.disable!
|
|
387
|
-
end
|
|
388
|
-
|
|
389
388
|
# Include the RSpec helpers and matchers
|
|
390
389
|
config.include ModelTimeline::RSpec
|
|
391
390
|
end
|
|
@@ -436,17 +435,65 @@ expect(user).to have_timeline_entries
|
|
|
436
435
|
expect(user).to have_timeline_entries(3)
|
|
437
436
|
|
|
438
437
|
# Check for entries with a specific action
|
|
439
|
-
expect(user).to
|
|
438
|
+
expect(user).to have_timeline_entry_action(:update)
|
|
440
439
|
|
|
441
440
|
# Check if a specific attribute was changed
|
|
442
|
-
expect(user).to
|
|
441
|
+
expect(user).to have_timeline_entry_change(:email)
|
|
443
442
|
|
|
444
443
|
# Check if an attribute was changed to a specific value
|
|
445
|
-
expect(user).to
|
|
444
|
+
expect(user).to have_timeline_entry(:status, 'active')
|
|
445
|
+
|
|
446
|
+
# Check if an entry was created with expected metadata
|
|
447
|
+
expect(user).to have_timeline_entry_metadata(foo: 'bar', baz: 'biz')
|
|
446
448
|
```
|
|
447
449
|
|
|
448
450
|
These matchers make it easy to test that your application is correctly tracking model changes.
|
|
449
451
|
|
|
452
|
+
##### Matchers for custom models/tables
|
|
453
|
+
|
|
454
|
+
You can add a configuration in your support file to create matchers for your association.
|
|
455
|
+
Given a model like this:
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
class User < ApplicationRecord
|
|
459
|
+
has_timeline :security_events, class_name: 'SecurityTimelineEntry'
|
|
460
|
+
end
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
You should set a configuration in your RSpec support file with:
|
|
464
|
+
```ruby
|
|
465
|
+
# spec/support/model_timeline.rb
|
|
466
|
+
require 'model_timeline/rspec'
|
|
467
|
+
|
|
468
|
+
RSpec.configure do |config|
|
|
469
|
+
# Include the RSpec helpers and matchers
|
|
470
|
+
config.include ModelTimeline::RSpec
|
|
471
|
+
config.include ModelTimeline::RSpec::Matchers.define_timeline_matchers_for(:security_events)
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Then you have those new matchers:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# Check for any timeline entries
|
|
479
|
+
expect(user).to have_security_events
|
|
480
|
+
|
|
481
|
+
# Check for a specific number of entries
|
|
482
|
+
expect(user).to have_security_events(3)
|
|
483
|
+
|
|
484
|
+
# Check for entries with a specific action
|
|
485
|
+
expect(user).to have_security_event_action(:update)
|
|
486
|
+
|
|
487
|
+
# Check if a specific attribute was changed
|
|
488
|
+
expect(user).to have_security_event_change(:email)
|
|
489
|
+
|
|
490
|
+
# Check if an attribute was changed to a specific value
|
|
491
|
+
expect(user).to have_security_event(:status, 'active')
|
|
492
|
+
|
|
493
|
+
# Check if an entry was created with expected metadata
|
|
494
|
+
expect(user).to have_security_event_metadata(foo: 'bar', baz: 'biz')
|
|
495
|
+
```
|
|
496
|
+
|
|
450
497
|
|
|
451
498
|
## License
|
|
452
499
|
|
|
@@ -7,6 +7,7 @@ class CreateModelTimelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migr
|
|
|
7
7
|
|
|
8
8
|
# Use PostgreSQL's JSONB type for better performance
|
|
9
9
|
t.jsonb :object_changes, default: {}, null: false
|
|
10
|
+
t.jsonb :metadata, default: {}, null: false
|
|
10
11
|
|
|
11
12
|
# Polymorphic user association
|
|
12
13
|
t.string :user_type
|
|
@@ -22,6 +23,7 @@ class CreateModelTimelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migr
|
|
|
22
23
|
add_index :<%= @table_name %>, [:timelineable_type, :timelineable_id], name: 'idx_timeline_on_timelineable'
|
|
23
24
|
add_index :<%= @table_name %>, [:user_type, :user_id], name: 'idx_timeline_on_user'
|
|
24
25
|
add_index :<%= @table_name %>, :object_changes, using: :gin, name: 'idx_timeline_on_changes'
|
|
26
|
+
add_index :<%= @table_name %>, :metadata, using: :gin, name: 'idx_timeline_on_meta'
|
|
25
27
|
add_index :<%= @table_name %>, :ip_address, name: 'idx_timeline_on_ip'
|
|
26
28
|
end
|
|
27
29
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module ModelTimeline
|
|
4
4
|
module RSpec
|
|
5
|
-
# rubocop:disable Naming/PredicateName
|
|
6
5
|
# Custom RSpec matchers for testing model timeline entries
|
|
7
6
|
#
|
|
8
7
|
# These matchers help you test the timeline entries created by the ModelTimeline gem.
|
|
@@ -16,58 +15,78 @@ module ModelTimeline
|
|
|
16
15
|
# expect(user).to have_timeline_entries(3)
|
|
17
16
|
#
|
|
18
17
|
# # Check for specific action
|
|
19
|
-
# expect(user).to
|
|
18
|
+
# expect(user).to have_timeline_entry_action(:update)
|
|
20
19
|
#
|
|
21
20
|
# # Check for changes to a specific attribute
|
|
22
|
-
# expect(user).to
|
|
21
|
+
# expect(user).to have_timeline_entry_change(:email)
|
|
23
22
|
#
|
|
24
23
|
# # Check for specific value change
|
|
25
|
-
# expect(user).to
|
|
24
|
+
# expect(user).to have_timeline_entry(:status, "active")
|
|
26
25
|
module Matchers
|
|
27
|
-
|
|
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)
|
|
26
|
+
def self.included(base)
|
|
27
|
+
base.include define_timeline_matchers_for(:timeline_entries)
|
|
37
28
|
end
|
|
38
29
|
|
|
39
|
-
|
|
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
|
|
30
|
+
def self.define_timeline_matchers_for(association_name)
|
|
31
|
+
association_name_singularized = association_name.to_s.singularize
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
33
|
+
Module.new do
|
|
34
|
+
# Check if a model has timeline entries
|
|
35
|
+
#
|
|
36
|
+
# @param count [Integer, nil] The expected number of timeline entries (optional)
|
|
37
|
+
# @return [HaveTimelineEntriesMatcher] A matcher that checks for timeline entries
|
|
38
|
+
# @example Without count parameter
|
|
39
|
+
# expect(user).to have_timeline_entries
|
|
40
|
+
# @example With count parameter
|
|
41
|
+
# expect(user).to have_timeline_entries(3)
|
|
42
|
+
define_method(:"have_#{association_name}") do |count = nil|
|
|
43
|
+
HaveTimelineEntriesMatcher.new(count, association_name)
|
|
44
|
+
end
|
|
60
45
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
46
|
+
# Check if a specific action was recorded in the timeline
|
|
47
|
+
#
|
|
48
|
+
# @param action [String, Symbol] The action name to look for
|
|
49
|
+
# @return [HaveTimelineAction] A matcher that checks for a specific action
|
|
50
|
+
# @example
|
|
51
|
+
# expect(user).to have_timeline_entry_action(:create)
|
|
52
|
+
# expect(user).to have_timeline_entry_action("update")
|
|
53
|
+
define_method(:"have_#{association_name_singularized}_action") do |action|
|
|
54
|
+
HaveTimelineAction.new(action, association_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if a specific attribute change was recorded in the timeline
|
|
58
|
+
#
|
|
59
|
+
# @param attribute [String, Symbol] The attribute name to check for changes
|
|
60
|
+
# @return [HaveTimelineChange] A matcher that checks if an attribute was changed
|
|
61
|
+
# @example
|
|
62
|
+
# expect(user).to have_timeline_entry_change(:email)
|
|
63
|
+
# expect(user).to have_timeline_entry_change("status")
|
|
64
|
+
define_method(:"have_#{association_name_singularized}_change") do |attribute|
|
|
65
|
+
HaveTimelineChange.new(attribute, association_name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if an attribute was changed to a specific value in the timeline
|
|
69
|
+
#
|
|
70
|
+
# @param attribute [String, Symbol] The attribute name to check
|
|
71
|
+
# @param value [Object] The value to check for
|
|
72
|
+
# @return [HaveTimelineEntry] A matcher that checks for specific attribute values
|
|
73
|
+
# @example
|
|
74
|
+
# expect(user).to have_timeline_entry(:status, "active")
|
|
75
|
+
# expect(user).to have_timeline_entry(:role, :admin)
|
|
76
|
+
define_method(:"have_#{association_name_singularized}") do |attribute, value|
|
|
77
|
+
HaveTimelineEntry.new(attribute, value, association_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if a model has timeline entries with specific metadata
|
|
81
|
+
#
|
|
82
|
+
# @param expected_metadata [Hash] The metadata key-value pairs to check for
|
|
83
|
+
# @return [HaveTimelineEntryMetadata] A matcher that checks for specific metadata
|
|
84
|
+
# @example
|
|
85
|
+
# expect(user).to have_timeline_entry_metadata(foo: 'bar', baz: 'biz')
|
|
86
|
+
define_method(:"have_#{association_name_singularized}_metadata") do |expected_metadata|
|
|
87
|
+
HaveTimelineEntryMetadata.new(expected_metadata, association_name)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
71
90
|
end
|
|
72
91
|
|
|
73
92
|
# RSpec matcher to check if a model has timeline entries
|
|
@@ -77,8 +96,9 @@ module ModelTimeline
|
|
|
77
96
|
# Initialize the matcher
|
|
78
97
|
#
|
|
79
98
|
# @param expected_count [Integer, nil] The expected number of timeline entries
|
|
80
|
-
def initialize(expected_count)
|
|
99
|
+
def initialize(expected_count, association_name)
|
|
81
100
|
@expected_count = expected_count
|
|
101
|
+
@association_name = association_name
|
|
82
102
|
end
|
|
83
103
|
|
|
84
104
|
# Check if the subject matches the expectations
|
|
@@ -88,9 +108,9 @@ module ModelTimeline
|
|
|
88
108
|
def matches?(subject)
|
|
89
109
|
@subject = subject
|
|
90
110
|
if @expected_count.nil?
|
|
91
|
-
subject.
|
|
111
|
+
subject.public_send(@association_name).any?
|
|
92
112
|
else
|
|
93
|
-
subject.
|
|
113
|
+
subject.public_send(@association_name).count == @expected_count
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
116
|
|
|
@@ -102,7 +122,7 @@ module ModelTimeline
|
|
|
102
122
|
"expected #{@subject} to have timeline entries, but found none"
|
|
103
123
|
else
|
|
104
124
|
"expected #{@subject} to have #{@expected_count} timeline entries, " \
|
|
105
|
-
"but found #{@subject.
|
|
125
|
+
"but found #{@subject.public_send(@association_name).count}"
|
|
106
126
|
end
|
|
107
127
|
end
|
|
108
128
|
|
|
@@ -111,7 +131,7 @@ module ModelTimeline
|
|
|
111
131
|
# @return [String] A descriptive failure message for negated expectations
|
|
112
132
|
def failure_message_when_negated
|
|
113
133
|
if @expected_count.nil?
|
|
114
|
-
"expected #{@subject} not to have any timeline entries, but found #{@subject.
|
|
134
|
+
"expected #{@subject} not to have any timeline entries, but found #{@subject.public_send(@association_name).count}"
|
|
115
135
|
else
|
|
116
136
|
"expected #{@subject} not to have #{@expected_count} timeline entries, but found exactly that many"
|
|
117
137
|
end
|
|
@@ -121,12 +141,13 @@ module ModelTimeline
|
|
|
121
141
|
# RSpec matcher to check if a model has timeline entries with a specific action
|
|
122
142
|
#
|
|
123
143
|
# @api private
|
|
124
|
-
class
|
|
144
|
+
class HaveTimelineAction
|
|
125
145
|
# Initialize the matcher
|
|
126
146
|
#
|
|
127
147
|
# @param action [String, Symbol] The action to look for
|
|
128
|
-
def initialize(action)
|
|
148
|
+
def initialize(action, association_name)
|
|
129
149
|
@action = action.to_s
|
|
150
|
+
@association_name = association_name
|
|
130
151
|
end
|
|
131
152
|
|
|
132
153
|
# Check if the subject matches the expectations
|
|
@@ -135,7 +156,7 @@ module ModelTimeline
|
|
|
135
156
|
# @return [Boolean] True if the model has timeline entries with the specified action
|
|
136
157
|
def matches?(subject)
|
|
137
158
|
@subject = subject
|
|
138
|
-
subject.
|
|
159
|
+
@subject.public_send(@association_name).where(action: @action).exists?
|
|
139
160
|
end
|
|
140
161
|
|
|
141
162
|
# Message displayed when the expectation fails
|
|
@@ -156,12 +177,13 @@ module ModelTimeline
|
|
|
156
177
|
# RSpec matcher to check if a model has timeline entries with changes to a specific attribute
|
|
157
178
|
#
|
|
158
179
|
# @api private
|
|
159
|
-
class
|
|
180
|
+
class HaveTimelineChange
|
|
160
181
|
# Initialize the matcher
|
|
161
182
|
#
|
|
162
183
|
# @param attribute [String, Symbol] The attribute to check for changes
|
|
163
|
-
def initialize(attribute)
|
|
184
|
+
def initialize(attribute, association_name)
|
|
164
185
|
@attribute = attribute.to_s
|
|
186
|
+
@association_name = association_name
|
|
165
187
|
end
|
|
166
188
|
|
|
167
189
|
# Check if the subject matches the expectations
|
|
@@ -170,7 +192,7 @@ module ModelTimeline
|
|
|
170
192
|
# @return [Boolean] True if the model has timeline entries with changes to the specified attribute
|
|
171
193
|
def matches?(subject)
|
|
172
194
|
@subject = subject
|
|
173
|
-
subject.
|
|
195
|
+
@subject.public_send(@association_name).with_changed_attribute(@attribute).exists?
|
|
174
196
|
end
|
|
175
197
|
|
|
176
198
|
# Message displayed when the expectation fails
|
|
@@ -191,14 +213,15 @@ module ModelTimeline
|
|
|
191
213
|
# RSpec matcher to check if a model has timeline entries where an attribute changed to a specific value
|
|
192
214
|
#
|
|
193
215
|
# @api private
|
|
194
|
-
class
|
|
216
|
+
class HaveTimelineEntry
|
|
195
217
|
# Initialize the matcher
|
|
196
218
|
#
|
|
197
219
|
# @param attribute [String, Symbol] The attribute to check
|
|
198
220
|
# @param value [Object] The value the attribute should have changed to
|
|
199
|
-
def initialize(attribute, value)
|
|
221
|
+
def initialize(attribute, value, association_name)
|
|
200
222
|
@attribute = attribute.to_s
|
|
201
223
|
@value = value
|
|
224
|
+
@association_name = association_name
|
|
202
225
|
end
|
|
203
226
|
|
|
204
227
|
# Check if the subject matches the expectations
|
|
@@ -207,7 +230,7 @@ module ModelTimeline
|
|
|
207
230
|
# @return [Boolean] True if the model has timeline entries where the attribute changed to the specified value
|
|
208
231
|
def matches?(subject)
|
|
209
232
|
@subject = subject
|
|
210
|
-
subject.
|
|
233
|
+
@subject.public_send(@association_name).with_changed_value(@attribute, @value).exists?
|
|
211
234
|
end
|
|
212
235
|
|
|
213
236
|
# Message displayed when the expectation fails
|
|
@@ -224,7 +247,52 @@ module ModelTimeline
|
|
|
224
247
|
"expected #{@subject} not to have tracked '#{@attribute}' changing to '#{@value}', but such a change was found"
|
|
225
248
|
end
|
|
226
249
|
end
|
|
250
|
+
|
|
251
|
+
# RSpec matcher to check if a model has timeline entries with specific metadata
|
|
252
|
+
#
|
|
253
|
+
# @api private
|
|
254
|
+
class HaveTimelineEntryMetadata
|
|
255
|
+
# Initialize the matcher
|
|
256
|
+
#
|
|
257
|
+
# @param expected_metadata [Hash] The metadata key-value pairs to check for
|
|
258
|
+
# @param association_name [Symbol] The name of the timeline association
|
|
259
|
+
def initialize(expected_metadata, association_name)
|
|
260
|
+
@expected_metadata = expected_metadata
|
|
261
|
+
@association_name = association_name
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Check if the subject matches the expectations
|
|
265
|
+
#
|
|
266
|
+
# @param subject [Object] The model to check for timeline entries
|
|
267
|
+
# @return [Boolean] True if the model has timeline entries with the specified metadata
|
|
268
|
+
def matches?(subject)
|
|
269
|
+
@subject = subject
|
|
270
|
+
|
|
271
|
+
# Build a query that checks for each key-value pair in the metadata
|
|
272
|
+
entries = subject.public_send(@association_name)
|
|
273
|
+
|
|
274
|
+
# Construct queries for each metadata key-value pair
|
|
275
|
+
@expected_metadata.all? do |key, value|
|
|
276
|
+
# Use a JSON containment query to check if the metadata contains the key-value pair
|
|
277
|
+
# This syntax works with PostgreSQL's JSONB containment operator @>
|
|
278
|
+
entries.where('metadata @> ?', { key.to_s => value }.to_json).exists?
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Message displayed when the expectation fails
|
|
283
|
+
#
|
|
284
|
+
# @return [String] A descriptive failure message
|
|
285
|
+
def failure_message
|
|
286
|
+
"expected #{@subject} to have timeline entries with metadata #{@expected_metadata.inspect}, but none was found"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Message displayed when the negated expectation fails
|
|
290
|
+
#
|
|
291
|
+
# @return [String] A descriptive failure message for negated expectations
|
|
292
|
+
def failure_message_when_negated
|
|
293
|
+
"expected #{@subject} not to have timeline entries with metadata #{@expected_metadata.inspect}, but such entries were found"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
227
296
|
end
|
|
228
|
-
# rubocop:enable Naming/PredicateName
|
|
229
297
|
end
|
|
230
298
|
end
|
data/lib/model_timeline/rspec.rb
CHANGED
|
@@ -20,7 +20,7 @@ module ModelTimeline
|
|
|
20
20
|
# describe User, :with_timeline do
|
|
21
21
|
# it "records timeline entries when updated" do
|
|
22
22
|
# user.update(name: "New Name")
|
|
23
|
-
# expect(user).to
|
|
23
|
+
# expect(user).to have_timeline_entry_change(:name)
|
|
24
24
|
# end
|
|
25
25
|
# end
|
|
26
26
|
module RSpec
|
|
@@ -107,9 +107,10 @@ module ModelTimeline
|
|
|
107
107
|
timelineable_id: id,
|
|
108
108
|
action: action,
|
|
109
109
|
object_changes: object_changes,
|
|
110
|
+
metadata: object_metadata(config),
|
|
110
111
|
ip_address: current_ip_address,
|
|
111
112
|
**current_user_attributes,
|
|
112
|
-
**
|
|
113
|
+
**column_metadata(config)
|
|
113
114
|
)
|
|
114
115
|
end
|
|
115
116
|
|
|
@@ -128,9 +129,10 @@ module ModelTimeline
|
|
|
128
129
|
timelineable_id: id,
|
|
129
130
|
action: 'destroy',
|
|
130
131
|
object_changes: {},
|
|
132
|
+
metadata: object_metadata(config),
|
|
131
133
|
ip_address: current_ip_address,
|
|
132
134
|
**current_user_attributes,
|
|
133
|
-
**
|
|
135
|
+
**column_metadata(config)
|
|
134
136
|
)
|
|
135
137
|
end
|
|
136
138
|
|
|
@@ -183,32 +185,64 @@ module ModelTimeline
|
|
|
183
185
|
|
|
184
186
|
# Collects metadata for the timeline entry
|
|
185
187
|
#
|
|
186
|
-
# @param meta_config [Hash] The metadata configuration
|
|
187
|
-
# @param config_key [String] The configuration key for this model
|
|
188
|
+
# @param meta_config [Hash, Proc] The metadata configuration
|
|
188
189
|
# @return [Hash] Collected metadata
|
|
189
|
-
def collect_metadata(meta_config
|
|
190
|
-
config = self.class.loggers[config_key]
|
|
190
|
+
def collect_metadata(meta_config)
|
|
191
191
|
metadata = {}
|
|
192
192
|
|
|
193
193
|
# First, add any thread-level metadata
|
|
194
194
|
metadata.merge!(ModelTimeline.metadata)
|
|
195
195
|
|
|
196
|
-
# Then, add any model-specific metadata
|
|
197
|
-
meta_config
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
# Then, add any model-specific metadata from config
|
|
197
|
+
metadata.merge!(resolve_metadata_value(meta_config))
|
|
198
|
+
|
|
199
|
+
metadata
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Recursively resolves metadata values
|
|
203
|
+
#
|
|
204
|
+
# @param value [Hash, Proc, Symbol, Object] The value to resolve
|
|
205
|
+
# @return [Hash, Object] The resolved value
|
|
206
|
+
def resolve_metadata_value(value)
|
|
207
|
+
case value
|
|
208
|
+
when Proc
|
|
209
|
+
# If it's a Proc, execute it and process the result recursively
|
|
210
|
+
proc_result = instance_exec(self, &value)
|
|
211
|
+
proc_result.is_a?(Hash) ? process_metadata_hash(proc_result) : proc_result
|
|
212
|
+
when Hash
|
|
213
|
+
# If it's a Hash, process each key-value pair
|
|
214
|
+
process_metadata_hash(value)
|
|
215
|
+
when Symbol
|
|
216
|
+
# If it's a Symbol, try to call the method
|
|
217
|
+
respond_to?(value) ? send(value) : value
|
|
218
|
+
else
|
|
219
|
+
# Any other value, return as-is
|
|
220
|
+
value
|
|
207
221
|
end
|
|
222
|
+
end
|
|
208
223
|
|
|
209
|
-
|
|
224
|
+
# Processes a metadata hash by resolving each value
|
|
225
|
+
#
|
|
226
|
+
# @param hash [Hash] The hash to process
|
|
227
|
+
# @return [Hash] The processed hash
|
|
228
|
+
def process_metadata_hash(hash)
|
|
229
|
+
result = {}
|
|
230
|
+
hash.each do |key, val|
|
|
231
|
+
result[key] = resolve_metadata_value(val)
|
|
232
|
+
end
|
|
233
|
+
result
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def column_metadata(config)
|
|
237
|
+
metadata = collect_metadata(config[:meta])
|
|
210
238
|
column_names = config[:klass].column_names.map(&:to_sym)
|
|
211
239
|
metadata.slice(*column_names)
|
|
212
240
|
end
|
|
241
|
+
|
|
242
|
+
def object_metadata(config)
|
|
243
|
+
metadata = collect_metadata(config[:meta])
|
|
244
|
+
column_names = config[:klass].column_names.map(&:to_sym)
|
|
245
|
+
metadata.except(*column_names)
|
|
246
|
+
end
|
|
213
247
|
end
|
|
214
248
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: model_timeline
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexandre Stapenhorst
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-06-
|
|
11
|
+
date: 2025-06-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pg
|