historiographer 4.0.0 → 4.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -39
  3. data/lib/historiographer/configuration.rb +36 -0
  4. data/lib/historiographer/history.rb +9 -2
  5. data/lib/historiographer/history_migration.rb +9 -6
  6. data/lib/historiographer/relation.rb +1 -1
  7. data/lib/historiographer/version.rb +3 -0
  8. data/lib/historiographer.rb +176 -11
  9. metadata +3 -30
  10. data/.document +0 -5
  11. data/.rspec +0 -1
  12. data/.ruby-version +0 -1
  13. data/.standalone_migrations +0 -6
  14. data/Gemfile +0 -34
  15. data/Gemfile.lock +0 -289
  16. data/Guardfile +0 -70
  17. data/Rakefile +0 -54
  18. data/VERSION +0 -1
  19. data/historiographer.gemspec +0 -106
  20. data/init.rb +0 -18
  21. data/spec/db/database.yml +0 -25
  22. data/spec/db/migrate/20161121212228_create_posts.rb +0 -19
  23. data/spec/db/migrate/20161121212229_create_post_histories.rb +0 -10
  24. data/spec/db/migrate/20161121212230_create_authors.rb +0 -13
  25. data/spec/db/migrate/20161121212231_create_author_histories.rb +0 -10
  26. data/spec/db/migrate/20161121212232_create_users.rb +0 -9
  27. data/spec/db/migrate/20171011194624_create_safe_posts.rb +0 -19
  28. data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +0 -9
  29. data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +0 -10
  30. data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +0 -11
  31. data/spec/db/migrate/20191024203106_create_thing_without_history.rb +0 -7
  32. data/spec/db/migrate/20221018204220_create_silent_posts.rb +0 -21
  33. data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +0 -9
  34. data/spec/db/schema.rb +0 -186
  35. data/spec/examples.txt +0 -32
  36. data/spec/factories/post.rb +0 -7
  37. data/spec/historiographer_spec.rb +0 -588
  38. data/spec/spec_helper.rb +0 -52
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f16398b9f6b0a3d58501a2115eabca0e0f93a938d04eeb4c21d3713f9b70079a
4
- data.tar.gz: a460ef65372e236b001225b1028837eb7ba107e8d45d958c39d003be0cd64db4
3
+ metadata.gz: 740453114d73fc300d55d887a57f8bbf447042bb5d65da886125ece34982bf33
4
+ data.tar.gz: 201cfd99a05408c4f4387d4c7244d87c985ce82c7aaab24db8114c7760831f31
5
5
  SHA512:
6
- metadata.gz: 3b545edcfe0076f0e83f5478347a9298fce77118d7274ad39ce8304844637b7ee15980e66c02dff36321eda4e36b0defdb10b6c33cc7486fba47e780043e5f42
7
- data.tar.gz: f828cf26cc357fb0b3e2750bfcc1f62dc628c1579f1fa3356062f34314545783bb6ecb80366e4ac9d6988eae8374168afeb0d29c166daa9dbd7c860074494de4
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
- 🤚Hands up if your `versions` table has gotten too big to query 🤚
11
+ 1. The `versions` table quickly grows too large to query
12
12
 
13
- 🤚Hands up if your `versions` table doesn't have the indexes you need 🤚
13
+ 2. It doesn't provide the indexes you need from your primary tables
14
14
 
15
- 🤚Hands up if you've ever iterated over `versions` records in Ruby to recreate a snapshot of what data looked like at a point in time. 🤚
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 | title |
34
- | :----------- | :----------- |
35
- | 1 | My Great Post |
36
- | 2 | My Second Post |
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 | post_id | title | history_started_at | history_ended_at | history_user_id |
41
- | :----------- | :----------- | :----------- | :----------- | :----------- | :----------- |
42
- | 1 | 1 | My Great Post | '2019-11-08' | NULL | 1 |
43
- | 2 | 2| My Second Post | '2019-11-08' | NULL | 1 |
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
- ```Post.find(1).update(title: "Title With Better SEO", history_user_id: current_user.id)```
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 | title |
52
- | :----------- | :----------- |
53
- | 1 | Title With Better SEO |
54
- | 2 | My Second Post |
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 | post_id | title | history_started_at | history_ended_at | history_user_id |
59
- | :----------- | :----------- | :----------- | :----------- | :----------- | :----------- |
60
- | 1 | 1 | My Great Post | '2019-11-08' | '2019-11-09' | 1 |
61
- | 2 | 2| My Second Post | '2019-11-08' | NULL | 1 |
62
- | 1 | 1 | Title With Better SEO | '2019-11-09' | NULL | 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
- By default, Historiographer will require all SQL-backed methods to provide a `history_user_id` to track who made the changes.
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
- Post.create(title: "My Post", history_user_id: current_user.id) # => OK
85
- Post.create(title: "My Post") # => Error!
153
+ # In an initializer
154
+ Historiographer::Configuration.mode = :histories # Default mode
155
+ # or
156
+ Historiographer::Configuration.mode = :snapshot_only
86
157
  ```
87
158
 
88
- Many existing models will be better off using `Historiographer::Safe` when getting started, which will not raise an error, but will alert you of locations where your app is missing `history_user_id`.
159
+ Or per model using `historiographer_mode`:
89
160
 
90
161
  ```ruby
91
162
  class Post < ActiveRecord::Base
92
- include Historiographer::Safe
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
- puts "Hello"
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.symbolize_keys!)
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|
@@ -11,7 +11,7 @@ module Historiographer
11
11
  end
12
12
 
13
13
  def update_all(updates, histories=true)
14
- unless histories
14
+ if !histories || self.model.is_history_class?
15
15
  super(updates)
16
16
  else
17
17
  updates.symbolize_keys!
@@ -0,0 +1,3 @@
1
+ module Historiographer
2
+ VERSION = "4.1.0"
3
+ end
@@ -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
- attr_accessor :history_user_id
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(ActiveRecord::Base) do
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
- current_history.update!(history_ended_at: now) if current_history.present?
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
- "#{name}History".constantize
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.0.0
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
- - spec/db/database.yml
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