historiographer 4.0.0 → 4.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +115 -39
- data/lib/historiographer/configuration.rb +36 -0
- data/lib/historiographer/history.rb +9 -2
- data/lib/historiographer/history_migration.rb +9 -6
- data/lib/historiographer/relation.rb +1 -1
- data/lib/historiographer/version.rb +3 -0
- data/lib/historiographer.rb +176 -11
- metadata +3 -30
- data/.document +0 -5
- data/.rspec +0 -1
- data/.ruby-version +0 -1
- data/.standalone_migrations +0 -6
- data/Gemfile +0 -34
- data/Gemfile.lock +0 -289
- data/Guardfile +0 -70
- data/Rakefile +0 -54
- data/VERSION +0 -1
- data/historiographer.gemspec +0 -106
- data/init.rb +0 -18
- data/spec/db/database.yml +0 -25
- data/spec/db/migrate/20161121212228_create_posts.rb +0 -19
- data/spec/db/migrate/20161121212229_create_post_histories.rb +0 -10
- data/spec/db/migrate/20161121212230_create_authors.rb +0 -13
- data/spec/db/migrate/20161121212231_create_author_histories.rb +0 -10
- data/spec/db/migrate/20161121212232_create_users.rb +0 -9
- data/spec/db/migrate/20171011194624_create_safe_posts.rb +0 -19
- data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +0 -9
- data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +0 -10
- data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +0 -11
- data/spec/db/migrate/20191024203106_create_thing_without_history.rb +0 -7
- data/spec/db/migrate/20221018204220_create_silent_posts.rb +0 -21
- data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +0 -9
- data/spec/db/schema.rb +0 -186
- data/spec/examples.txt +0 -32
- data/spec/factories/post.rb +0 -7
- data/spec/historiographer_spec.rb +0 -588
- data/spec/spec_helper.rb +0 -52
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 740453114d73fc300d55d887a57f8bbf447042bb5d65da886125ece34982bf33
|
4
|
+
data.tar.gz: 201cfd99a05408c4f4387d4c7244d87c985ce82c7aaab24db8114c7760831f31
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 93d61f00c711ef9ef058e1e3eef9444755ea24756d5bb139cd2f657396061f80d9f405ed6eef4d86164fad8b10abd7e9103d0776556a2d1d344b379dab9d6076
|
7
|
+
data.tar.gz: acfe88c0b2fad42d24484b62fcd3cae84149eca55d0aea1d1f6771a5c6e785508679a5430f533d427c6aa460a8a5f355023f50ad42614282ae5d7b88591aaf19
|
data/README.md
CHANGED
@@ -8,21 +8,11 @@ Historiographer fixes this problem in a better way than existing auditing gems.
|
|
8
8
|
|
9
9
|
The Audited gem has some serious flaws.
|
10
10
|
|
11
|
-
|
11
|
+
1. The `versions` table quickly grows too large to query
|
12
12
|
|
13
|
-
|
13
|
+
2. It doesn't provide the indexes you need from your primary tables
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
Why does this happen?
|
18
|
-
|
19
|
-
First, `audited` only tracks a record of what changed, so there's no way to "go back in time" and see what the data looked like back when a problem occurred without replaying every single audit.
|
20
|
-
|
21
|
-
Second, it tracks changes as JSON. While some data stores have JSON querying semantics, not all do, making it very hard to ask complex questions of your historical data -- that's the whole reason you're keeping it around.
|
22
|
-
|
23
|
-
Third, it doesn't maintain indexes on your data. If you maintain an index on the primary table, wouldn't you also want to look up historical records using the same columns? Historical data is MUCH larger than "latest snapshot" data, so, duh, of course you do.
|
24
|
-
|
25
|
-
Finally, Audited creates just one table for all audits. Historical data is big. It's not unusual for an audited gem table to get into the many millions of rows, and need to be constantly partitioned to maintain any kind of efficiency.
|
15
|
+
3. It doesn't provdie out-of-the-box snapshots
|
26
16
|
|
27
17
|
## How does Historiographer solve these problems?
|
28
18
|
|
@@ -30,44 +20,116 @@ Historiographer introduces the concept of _history tables:_ append-only tables t
|
|
30
20
|
|
31
21
|
If you have a `posts` table:
|
32
22
|
|
33
|
-
| id
|
34
|
-
|
|
35
|
-
| 1
|
36
|
-
| 2
|
23
|
+
| id | title |
|
24
|
+
| :-- | :------------- |
|
25
|
+
| 1 | My Great Post |
|
26
|
+
| 2 | My Second Post |
|
37
27
|
|
38
28
|
You'll also have a `post_histories_table`:
|
39
29
|
|
40
|
-
| id
|
41
|
-
|
|
42
|
-
| 1
|
43
|
-
| 2
|
30
|
+
| id | post_id | title | history_started_at | history_ended_at | history_user_id |
|
31
|
+
| :-- | :------ | :------------- | :----------------- | :--------------- | :-------------- |
|
32
|
+
| 1 | 1 | My Great Post | '2019-11-08' | NULL | 1 |
|
33
|
+
| 2 | 2 | My Second Post | '2019-11-08' | NULL | 1 |
|
44
34
|
|
45
35
|
If you change the title of the 1st post:
|
46
36
|
|
47
|
-
|
37
|
+
`Post.find(1).update(title: "Title With Better SEO", history_user_id: current_user.id)`
|
48
38
|
|
49
39
|
You'll expect your `posts` table to be updated directly:
|
50
40
|
|
51
|
-
| id
|
52
|
-
|
|
53
|
-
| 1
|
54
|
-
| 2
|
41
|
+
| id | title |
|
42
|
+
| :-- | :-------------------- |
|
43
|
+
| 1 | Title With Better SEO |
|
44
|
+
| 2 | My Second Post |
|
55
45
|
|
56
46
|
But also, your `histories` table will be updated:
|
57
47
|
|
58
|
-
| id
|
59
|
-
|
|
60
|
-
| 1
|
61
|
-
| 2
|
62
|
-
| 1
|
48
|
+
| id | post_id | title | history_started_at | history_ended_at | history_user_id |
|
49
|
+
| :-- | :------ | :-------------------- | :----------------- | :--------------- | :-------------- |
|
50
|
+
| 1 | 1 | My Great Post | '2019-11-08' | '2019-11-09' | 1 |
|
51
|
+
| 2 | 2 | My Second Post | '2019-11-08' | NULL | 1 |
|
52
|
+
| 1 | 1 | Title With Better SEO | '2019-11-09' | NULL | 1 |
|
63
53
|
|
64
54
|
A few things have happened here:
|
65
55
|
|
66
56
|
1. The primary table (`posts`) is updated directly
|
67
57
|
2. The existing history for `post_id=1` is timestamped when its `history_ended_at`, so that we can see when the post had the title "My Great Post"
|
68
|
-
3. A new history record is appended to the table containing a complete snapshot of the record, and a `NULL` `history_ended_at`. That's because this is the current history.
|
58
|
+
3. A new history record is appended to the table containing a complete snapshot of the record, and a `NULL` `history_ended_at`. That's because this is the current history.
|
69
59
|
4. A record of _who_ made the change is saved (`history_user_id`). You can join to your users table to see more data.
|
70
60
|
|
61
|
+
## Snapshots
|
62
|
+
|
63
|
+
Snapshots are particularly useful for two key use cases:
|
64
|
+
|
65
|
+
### 1. Time Travel & Auditing
|
66
|
+
|
67
|
+
When you need to see exactly what your data looked like at a specific point in time - not just individual records, but entire object graphs with all their associations. This is invaluable for:
|
68
|
+
|
69
|
+
- Debugging production issues ("What did the entire order look like when this happened?")
|
70
|
+
- Compliance requirements ("Show me the exact state of this patient's record on January 1st")
|
71
|
+
- Auditing complex workflows ("What was the state of this loan application when it was approved?")
|
72
|
+
|
73
|
+
### 2. Machine Learning & Analytics
|
74
|
+
|
75
|
+
When you need immutable snapshots of data for:
|
76
|
+
|
77
|
+
- Training data versioning
|
78
|
+
- Feature engineering
|
79
|
+
- Model validation
|
80
|
+
- A/B test analysis
|
81
|
+
- Ensuring reproducibility of results
|
82
|
+
|
83
|
+
### Taking Snapshots
|
84
|
+
|
85
|
+
You can take a snapshot of a record and all its associated records:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
post = Post.find(1)
|
89
|
+
post.snapshot(history_user_id: current_user.id)
|
90
|
+
```
|
91
|
+
|
92
|
+
This will:
|
93
|
+
|
94
|
+
1. Create a history record for the post
|
95
|
+
2. Create history records for all associated records (comments, author, etc.)
|
96
|
+
3. Link these history records together with a shared `snapshot_id`
|
97
|
+
|
98
|
+
You can retrieve the latest snapshot using:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
post = Post.find(1)
|
102
|
+
snapshot = post.latest_snapshot
|
103
|
+
|
104
|
+
# Access associated records from the snapshot
|
105
|
+
snapshot.comments # Returns CommentHistory records
|
106
|
+
snapshot.author # Returns AuthorHistory record
|
107
|
+
```
|
108
|
+
|
109
|
+
Snapshots are immutable - you cannot modify history records that are part of a snapshot. This guarantees that your historical data remains unchanged, which is crucial for both auditing and machine learning applications.
|
110
|
+
|
111
|
+
### Snapshot-Only Mode
|
112
|
+
|
113
|
+
If you want to only track snapshots and not record every individual change, you can configure Historiographer to operate in snapshot-only mode:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
Historiographer::Configuration.mode = :snapshot_only
|
117
|
+
```
|
118
|
+
|
119
|
+
In this mode:
|
120
|
+
|
121
|
+
- Regular updates/changes will not create history records
|
122
|
+
- Only explicit calls to `snapshot` will create history records
|
123
|
+
- Each snapshot still captures the complete state of the record and its associations
|
124
|
+
|
125
|
+
This can be useful when:
|
126
|
+
|
127
|
+
- You only care about specific points in time rather than every change
|
128
|
+
- You want to reduce the number of history records created
|
129
|
+
- You need to capture the state of complex object graphs at specific moments
|
130
|
+
- You're versioning training data for machine learning models
|
131
|
+
- You need to maintain immutable audit trails at specific checkpoints
|
132
|
+
|
71
133
|
# Getting Started
|
72
134
|
|
73
135
|
Whenever you include the `Historiographer` gem in your ActiveRecord model, it allows you to insert, update, or delete data as you normally would.
|
@@ -78,18 +140,33 @@ class Post < ActiveRecord::Base
|
|
78
140
|
end
|
79
141
|
```
|
80
142
|
|
81
|
-
|
143
|
+
### History Modes
|
144
|
+
|
145
|
+
Historiographer supports two modes of operation:
|
146
|
+
|
147
|
+
1. **:histories mode** (default) - Records history for every change to a record
|
148
|
+
2. **:snapshot_only mode** - Only records history when explicitly taking snapshots
|
149
|
+
|
150
|
+
You can configure the mode globally:
|
82
151
|
|
83
152
|
```ruby
|
84
|
-
|
85
|
-
|
153
|
+
# In an initializer
|
154
|
+
Historiographer::Configuration.mode = :histories # Default mode
|
155
|
+
# or
|
156
|
+
Historiographer::Configuration.mode = :snapshot_only
|
86
157
|
```
|
87
158
|
|
88
|
-
|
159
|
+
Or per model using `historiographer_mode`:
|
89
160
|
|
90
161
|
```ruby
|
91
162
|
class Post < ActiveRecord::Base
|
92
|
-
include Historiographer
|
163
|
+
include Historiographer
|
164
|
+
historiographer_mode :snapshot_only # Only record history when .snapshot is called
|
165
|
+
end
|
166
|
+
|
167
|
+
class Comment < ActiveRecord::Base
|
168
|
+
include Historiographer
|
169
|
+
historiographer_mode :histories # Record history for every change (default)
|
93
170
|
end
|
94
171
|
```
|
95
172
|
|
@@ -210,12 +287,11 @@ end
|
|
210
287
|
== Mysql Install
|
211
288
|
|
212
289
|
For contributors on OSX, you may have difficulty installing mysql:
|
213
|
-
|
290
|
+
|
214
291
|
```
|
215
292
|
gem install mysql2 -v '0.4.10' --source 'https://rubygems.org/' -- --with-ldflags=-L/usr/local/opt/openssl/lib --with-cppflags=-I/usr/local/opt/openssl/include
|
216
293
|
```
|
217
294
|
|
218
|
-
|
219
295
|
== Copyright
|
220
296
|
|
221
297
|
Copyright (c) 2016-2020 brettshollenberger. See LICENSE.txt for
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require "singleton"
|
2
|
+
|
3
|
+
module Historiographer
|
4
|
+
class Configuration
|
5
|
+
include Singleton
|
6
|
+
|
7
|
+
OPTS = {
|
8
|
+
mode: {
|
9
|
+
default: :histories
|
10
|
+
}
|
11
|
+
}
|
12
|
+
OPTS.each do |key, options|
|
13
|
+
attr_accessor key
|
14
|
+
end
|
15
|
+
|
16
|
+
class << self
|
17
|
+
def configure
|
18
|
+
yield instance
|
19
|
+
end
|
20
|
+
|
21
|
+
OPTS.each do |key, options|
|
22
|
+
define_method "#{key}=" do |value|
|
23
|
+
instance.send("#{key}=", value)
|
24
|
+
end
|
25
|
+
|
26
|
+
define_method key do
|
27
|
+
instance.send(key) || options.dig(:default)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize
|
33
|
+
@mode = :histories
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -139,7 +139,7 @@ module Historiographer
|
|
139
139
|
# If the record was not already persisted, proceed as normal.
|
140
140
|
#
|
141
141
|
def save(*args)
|
142
|
-
if persisted? && (changes.keys - %w(history_ended_at)).any?
|
142
|
+
if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
|
143
143
|
false
|
144
144
|
else
|
145
145
|
super
|
@@ -147,12 +147,19 @@ module Historiographer
|
|
147
147
|
end
|
148
148
|
|
149
149
|
def save!(*args)
|
150
|
-
if persisted? && (changes.keys - %w(history_ended_at)).any?
|
150
|
+
if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
|
151
151
|
false
|
152
152
|
else
|
153
153
|
super
|
154
154
|
end
|
155
155
|
end
|
156
|
+
|
157
|
+
# Returns the most recent snapshot for each snapshot_id
|
158
|
+
# Orders by history_started_at and id to handle cases where multiple records
|
159
|
+
# have the same history_started_at timestamp
|
160
|
+
scope :latest_snapshot, -> {
|
161
|
+
where.not(snapshot_id: nil).order('id DESC').limit(1)&.first
|
162
|
+
}
|
156
163
|
end
|
157
164
|
|
158
165
|
class_methods do
|
@@ -29,22 +29,24 @@ module Historiographer
|
|
29
29
|
original_columns = klass.columns.reject { |c| c.name == "id" || except.include?(c.name) || (only.any? && only.exclude?(c.name)) || no_business_columns }
|
30
30
|
|
31
31
|
integer foreign_key.to_sym, null: false
|
32
|
+
valid_keys = [:limit, :precision, :scale, :default, :null, :collation, :comment,
|
33
|
+
:primary_key, :if_exists, :if_not_exists, :array, :using,
|
34
|
+
:cast_as, :as, :type, :enum_type, :stored]
|
32
35
|
|
33
36
|
original_columns.each do |column|
|
34
|
-
opts =
|
35
|
-
opts.merge!(column.as_json.clone)
|
37
|
+
opts = column.as_json.symbolize_keys.slice(*valid_keys) # Only keep valid keys
|
36
38
|
|
37
39
|
if RUBY_VERSION.to_i >= 3
|
38
|
-
|
39
|
-
send(column.type, column.name, **opts.symbolize_keys!)
|
40
|
+
send(column.type, column.name, **opts)
|
40
41
|
else
|
41
|
-
send(column.type, column.name, opts
|
42
|
+
send(column.type, column.name, opts)
|
42
43
|
end
|
43
44
|
end
|
44
45
|
|
45
46
|
datetime :history_started_at, null: false
|
46
47
|
datetime :history_ended_at
|
47
48
|
integer :history_user_id
|
49
|
+
string :snapshot_id
|
48
50
|
|
49
51
|
indices_sql = %q(
|
50
52
|
SELECT
|
@@ -75,7 +77,8 @@ module Historiographer
|
|
75
77
|
foreign_key,
|
76
78
|
:history_started_at,
|
77
79
|
:history_ended_at,
|
78
|
-
:history_user_id
|
80
|
+
:history_user_id,
|
81
|
+
:snapshot_id
|
79
82
|
])
|
80
83
|
|
81
84
|
indexes.each do |index_definition|
|
data/lib/historiographer.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'active_support/all'
|
4
|
+
require 'securerandom'
|
4
5
|
require_relative './historiographer/history'
|
5
6
|
require_relative './historiographer/postgres_migration'
|
6
7
|
require_relative './historiographer/safe'
|
@@ -83,12 +84,21 @@ module Historiographer
|
|
83
84
|
after_save :record_history, if: :should_record_history?
|
84
85
|
validate :validate_history_user_id_present, if: :should_validate_history_user_id_present?
|
85
86
|
|
87
|
+
# Add scope to fetch latest histories
|
88
|
+
scope :latest_snapshot, -> {
|
89
|
+
history_class.latest_snapshot
|
90
|
+
}
|
91
|
+
|
92
|
+
def should_alert_history_user_id_present?
|
93
|
+
!snapshot_mode? && !is_history_class? && Thread.current[:skip_history_user_id_validation] != true
|
94
|
+
end
|
95
|
+
|
86
96
|
def should_validate_history_user_id_present?
|
87
|
-
true
|
97
|
+
!snapshot_mode? && !is_history_class? && Thread.current[:skip_history_user_id_validation] != true
|
88
98
|
end
|
89
99
|
|
90
100
|
def validate_history_user_id_present
|
91
|
-
if @no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer))
|
101
|
+
if should_validate_history_user_id_present? && (@no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer)))
|
92
102
|
errors.add(:history_user_id, 'must be an integer')
|
93
103
|
end
|
94
104
|
end
|
@@ -139,9 +149,9 @@ module Historiographer
|
|
139
149
|
def historiographer_changes?
|
140
150
|
case Rails.version.to_f
|
141
151
|
when 0..5 then changed? && valid?
|
142
|
-
when 5.1..7 then saved_changes?
|
143
|
-
else
|
144
152
|
raise 'Unsupported Rails version'
|
153
|
+
when 5.1..8 then saved_changes?
|
154
|
+
else
|
145
155
|
end
|
146
156
|
end
|
147
157
|
|
@@ -151,17 +161,36 @@ module Historiographer
|
|
151
161
|
# then record history after successful save.
|
152
162
|
#
|
153
163
|
def should_record_history?
|
164
|
+
return false if snapshot_mode?
|
165
|
+
return false if is_history_class?
|
166
|
+
|
154
167
|
historiographer_changes? && !@no_history
|
155
168
|
end
|
156
169
|
|
157
|
-
|
170
|
+
def history_user_id=(value)
|
171
|
+
if is_history_class?
|
172
|
+
write_attribute(:history_user_id, value)
|
173
|
+
else
|
174
|
+
@history_user_id = value
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def history_user_id
|
179
|
+
if is_history_class?
|
180
|
+
read_attribute(:history_user_id)
|
181
|
+
else
|
182
|
+
@history_user_id
|
183
|
+
end
|
184
|
+
end
|
158
185
|
|
159
186
|
class_name = "#{base.name}History"
|
160
187
|
|
161
188
|
begin
|
162
189
|
class_name.constantize
|
163
190
|
rescue StandardError
|
164
|
-
history_class_initializer = Class.new(
|
191
|
+
history_class_initializer = Class.new(base) do
|
192
|
+
self.table_name = "#{base.table_name}_histories"
|
193
|
+
self.inheritance_column = nil
|
165
194
|
end
|
166
195
|
|
167
196
|
Object.const_set(class_name, history_class_initializer)
|
@@ -169,6 +198,70 @@ module Historiographer
|
|
169
198
|
|
170
199
|
klass = class_name.constantize
|
171
200
|
|
201
|
+
# Hook into the association building process
|
202
|
+
base.singleton_class.prepend(Module.new do
|
203
|
+
def belongs_to(name, scope = nil, **options, &extension)
|
204
|
+
super
|
205
|
+
define_history_association(name, :belongs_to, options)
|
206
|
+
end
|
207
|
+
|
208
|
+
def has_one(name, scope = nil, **options, &extension)
|
209
|
+
super
|
210
|
+
define_history_association(name, :has_one, options)
|
211
|
+
end
|
212
|
+
|
213
|
+
def has_many(name, scope = nil, **options, &extension)
|
214
|
+
super
|
215
|
+
define_history_association(name, :has_many, options)
|
216
|
+
end
|
217
|
+
|
218
|
+
def has_and_belongs_to_many(name, scope = nil, **options, &extension)
|
219
|
+
super
|
220
|
+
define_history_association(name, :has_and_belongs_to_many, options)
|
221
|
+
end
|
222
|
+
|
223
|
+
private
|
224
|
+
|
225
|
+
def define_history_association(name, type, options)
|
226
|
+
return if is_history_class?
|
227
|
+
return if @defining_association
|
228
|
+
return if %i[histories current_history].include?(name)
|
229
|
+
@defining_association = true
|
230
|
+
|
231
|
+
history_class = "#{self.name}History".constantize
|
232
|
+
history_class_name = "#{name.to_s.singularize.camelize}History"
|
233
|
+
|
234
|
+
# Get the original association's foreign key
|
235
|
+
original_reflection = self.reflect_on_association(name)
|
236
|
+
foreign_key = original_reflection.foreign_key
|
237
|
+
|
238
|
+
if type == :has_many || type == :has_and_belongs_to_many
|
239
|
+
history_class.send(
|
240
|
+
type,
|
241
|
+
name,
|
242
|
+
-> (owner) { where("#{name.to_s.singularize}_histories.snapshot_id = ?", owner.snapshot_id) },
|
243
|
+
**options.merge(
|
244
|
+
class_name: history_class_name,
|
245
|
+
foreign_key: foreign_key,
|
246
|
+
primary_key: foreign_key
|
247
|
+
)
|
248
|
+
)
|
249
|
+
else
|
250
|
+
history_class.send(
|
251
|
+
type,
|
252
|
+
name,
|
253
|
+
-> (owner) { where("#{name}_histories.snapshot_id = ?", owner.snapshot_id) },
|
254
|
+
**options.merge(
|
255
|
+
class_name: history_class_name,
|
256
|
+
foreign_key: foreign_key,
|
257
|
+
primary_key: foreign_key
|
258
|
+
)
|
259
|
+
)
|
260
|
+
end
|
261
|
+
@defining_association = false
|
262
|
+
end
|
263
|
+
end)
|
264
|
+
|
172
265
|
if base.respond_to?(:histories)
|
173
266
|
raise "#{base} already has histories. Talk to Brett if this is a legit use case."
|
174
267
|
else
|
@@ -219,6 +312,46 @@ module Historiographer
|
|
219
312
|
@no_history = false
|
220
313
|
end
|
221
314
|
|
315
|
+
|
316
|
+
def snapshot(tree = {}, snapshot_id = nil)
|
317
|
+
return if is_history_class?
|
318
|
+
|
319
|
+
without_history_user_id do
|
320
|
+
# Use SecureRandom.uuid instead of timestamp for snapshot_id
|
321
|
+
snapshot_id ||= SecureRandom.uuid
|
322
|
+
history_class = self.class.history_class
|
323
|
+
primary_key = self.class.primary_key
|
324
|
+
foreign_key = history_class.history_foreign_key
|
325
|
+
attrs = attributes.clone
|
326
|
+
existing_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: snapshot_id)
|
327
|
+
return if existing_snapshot.present?
|
328
|
+
|
329
|
+
null_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: nil)
|
330
|
+
if null_snapshot.present?
|
331
|
+
null_snapshot.update(snapshot_id: snapshot_id)
|
332
|
+
else
|
333
|
+
record_history(snapshot_id: snapshot_id)
|
334
|
+
end
|
335
|
+
|
336
|
+
# Recursively snapshot associations, avoiding infinite loops
|
337
|
+
self.class.reflect_on_all_associations.each do |association|
|
338
|
+
associated_records = send(association.name).reload
|
339
|
+
Array(associated_records).each do |record|
|
340
|
+
model_name = record.class.name
|
341
|
+
record_id = record.id
|
342
|
+
|
343
|
+
tree[model_name] ||= {}
|
344
|
+
next if tree[model_name][record_id]
|
345
|
+
|
346
|
+
new_tree = tree.deep_dup
|
347
|
+
new_tree[model_name][record_id] = true
|
348
|
+
|
349
|
+
record.snapshot(new_tree, snapshot_id) if record.respond_to?(:snapshot)
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
222
355
|
private
|
223
356
|
|
224
357
|
def history_user_absent_action
|
@@ -231,8 +364,8 @@ module Historiographer
|
|
231
364
|
#
|
232
365
|
# Find the most recent history, and update its history_ended_at timestamp
|
233
366
|
#
|
234
|
-
def record_history
|
235
|
-
history_user_absent_action if history_user_id.nil?
|
367
|
+
def record_history(snapshot_id: nil)
|
368
|
+
history_user_absent_action if history_user_id.nil? && should_alert_history_user_id_present?
|
236
369
|
|
237
370
|
attrs = attributes.clone
|
238
371
|
history_class = self.class.history_class
|
@@ -240,30 +373,62 @@ module Historiographer
|
|
240
373
|
|
241
374
|
now = UTC.now
|
242
375
|
attrs.merge!(foreign_key => attrs['id'], history_started_at: now, history_user_id: history_user_id)
|
376
|
+
attrs.merge!(snapshot_id: snapshot_id) if snapshot_id.present?
|
243
377
|
|
244
378
|
attrs = attrs.except('id')
|
245
379
|
|
246
380
|
current_history = histories.where(history_ended_at: nil).order('id desc').limit(1).last
|
247
381
|
|
248
382
|
if foreign_key.present? && history_class.present?
|
249
|
-
history_class.create!(attrs)
|
250
|
-
|
383
|
+
history_class.create!(attrs).tap do |history|
|
384
|
+
current_history.update!(history_ended_at: now) if current_history.present?
|
385
|
+
end
|
251
386
|
else
|
252
387
|
raise 'Need foreign key and history class to save history!'
|
253
388
|
end
|
254
389
|
end
|
390
|
+
|
391
|
+
def without_history_user_id
|
392
|
+
Thread.current[:skip_history_user_id_validation] = true
|
393
|
+
yield
|
394
|
+
ensure
|
395
|
+
Thread.current[:skip_history_user_id_validation] = false
|
396
|
+
end
|
255
397
|
end
|
256
398
|
|
257
399
|
class_methods do
|
400
|
+
def is_history_class?
|
401
|
+
name.match?(/History$/)
|
402
|
+
end
|
258
403
|
#
|
259
404
|
# E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory
|
260
405
|
#
|
261
406
|
def history_class
|
262
|
-
|
407
|
+
if is_history_class?
|
408
|
+
nil
|
409
|
+
else
|
410
|
+
"#{name}History".constantize
|
411
|
+
end
|
263
412
|
end
|
264
413
|
|
265
414
|
def relation
|
266
415
|
super.tap { |r| r.extend Historiographer::Relation }
|
267
416
|
end
|
417
|
+
|
418
|
+
def historiographer_mode(mode)
|
419
|
+
@historiographer_mode = mode
|
420
|
+
end
|
421
|
+
|
422
|
+
def get_historiographer_mode
|
423
|
+
@historiographer_mode || Historiographer::Configuration.mode
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
def is_history_class?
|
428
|
+
self.class.is_history_class?
|
429
|
+
end
|
430
|
+
|
431
|
+
def snapshot_mode?
|
432
|
+
(self.class.get_historiographer_mode.to_sym == :snapshot_only)
|
268
433
|
end
|
269
434
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: historiographer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- brettshollenberger
|
@@ -228,20 +228,10 @@ extra_rdoc_files:
|
|
228
228
|
- LICENSE.txt
|
229
229
|
- README.md
|
230
230
|
files:
|
231
|
-
- ".document"
|
232
|
-
- ".rspec"
|
233
|
-
- ".ruby-version"
|
234
|
-
- ".standalone_migrations"
|
235
|
-
- Gemfile
|
236
|
-
- Gemfile.lock
|
237
|
-
- Guardfile
|
238
231
|
- LICENSE.txt
|
239
232
|
- README.md
|
240
|
-
- Rakefile
|
241
|
-
- VERSION
|
242
|
-
- historiographer.gemspec
|
243
|
-
- init.rb
|
244
233
|
- lib/historiographer.rb
|
234
|
+
- lib/historiographer/configuration.rb
|
245
235
|
- lib/historiographer/history.rb
|
246
236
|
- lib/historiographer/history_migration.rb
|
247
237
|
- lib/historiographer/history_migration_mysql.rb
|
@@ -250,24 +240,7 @@ files:
|
|
250
240
|
- lib/historiographer/relation.rb
|
251
241
|
- lib/historiographer/safe.rb
|
252
242
|
- lib/historiographer/silent.rb
|
253
|
-
-
|
254
|
-
- spec/db/migrate/20161121212228_create_posts.rb
|
255
|
-
- spec/db/migrate/20161121212229_create_post_histories.rb
|
256
|
-
- spec/db/migrate/20161121212230_create_authors.rb
|
257
|
-
- spec/db/migrate/20161121212231_create_author_histories.rb
|
258
|
-
- spec/db/migrate/20161121212232_create_users.rb
|
259
|
-
- spec/db/migrate/20171011194624_create_safe_posts.rb
|
260
|
-
- spec/db/migrate/20171011194715_create_safe_post_histories.rb
|
261
|
-
- spec/db/migrate/20191024142304_create_thing_with_compound_index.rb
|
262
|
-
- spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb
|
263
|
-
- spec/db/migrate/20191024203106_create_thing_without_history.rb
|
264
|
-
- spec/db/migrate/20221018204220_create_silent_posts.rb
|
265
|
-
- spec/db/migrate/20221018204255_create_silent_post_histories.rb
|
266
|
-
- spec/db/schema.rb
|
267
|
-
- spec/examples.txt
|
268
|
-
- spec/factories/post.rb
|
269
|
-
- spec/historiographer_spec.rb
|
270
|
-
- spec/spec_helper.rb
|
243
|
+
- lib/historiographer/version.rb
|
271
244
|
homepage: http://github.com/brettshollenberger/historiographer
|
272
245
|
licenses:
|
273
246
|
- MIT
|