historiographer 3.1.2 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -39
  3. data/{Gemfile → Users/brettshollenberger/programming/historiographer/Gemfile} +1 -2
  4. data/Users/brettshollenberger/programming/historiographer/Gemfile.lock +341 -0
  5. data/Users/brettshollenberger/programming/historiographer/Guardfile +4 -0
  6. data/Users/brettshollenberger/programming/historiographer/LICENSE.txt +20 -0
  7. data/Users/brettshollenberger/programming/historiographer/README.md +298 -0
  8. data/Users/brettshollenberger/programming/historiographer/historiographer-4.1.0.gem +0 -0
  9. data/{historiographer.gemspec → Users/brettshollenberger/programming/historiographer/historiographer.gemspec} +5 -46
  10. data/Users/brettshollenberger/programming/historiographer/lib/historiographer/configuration.rb +36 -0
  11. data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/history.rb +9 -2
  12. data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/history_migration.rb +13 -5
  13. data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/relation.rb +1 -1
  14. data/Users/brettshollenberger/programming/historiographer/lib/historiographer/version.rb +3 -0
  15. data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer.rb +183 -13
  16. data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/database.yml +5 -3
  17. data/Users/brettshollenberger/programming/historiographer/spec/db/migrate/20241109182017_create_comments.rb +13 -0
  18. data/Users/brettshollenberger/programming/historiographer/spec/db/migrate/20241109182020_create_comment_histories.rb +9 -0
  19. data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/schema.rb +80 -41
  20. data/Users/brettshollenberger/programming/historiographer/spec/examples.txt +40 -0
  21. data/{spec → Users/brettshollenberger/programming/historiographer/spec}/historiographer_spec.rb +265 -40
  22. data/{spec → Users/brettshollenberger/programming/historiographer/spec}/spec_helper.rb +8 -4
  23. metadata +43 -41
  24. data/.document +0 -5
  25. data/.rspec +0 -1
  26. data/.ruby-version +0 -1
  27. data/.standalone_migrations +0 -6
  28. data/Gemfile.lock +0 -289
  29. data/Guardfile +0 -70
  30. data/VERSION +0 -1
  31. data/spec/examples.txt +0 -29
  32. /data/{Rakefile → Users/brettshollenberger/programming/historiographer/Rakefile} +0 -0
  33. /data/{init.rb → Users/brettshollenberger/programming/historiographer/init.rb} +0 -0
  34. /data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/history_migration_mysql.rb +0 -0
  35. /data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/mysql_migration.rb +0 -0
  36. /data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/postgres_migration.rb +0 -0
  37. /data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/safe.rb +0 -0
  38. /data/{lib → Users/brettshollenberger/programming/historiographer/lib}/historiographer/silent.rb +0 -0
  39. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20161121212228_create_posts.rb +0 -0
  40. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20161121212229_create_post_histories.rb +0 -0
  41. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20161121212230_create_authors.rb +0 -0
  42. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20161121212231_create_author_histories.rb +0 -0
  43. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20161121212232_create_users.rb +0 -0
  44. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20171011194624_create_safe_posts.rb +0 -0
  45. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20171011194715_create_safe_post_histories.rb +0 -0
  46. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20191024142304_create_thing_with_compound_index.rb +0 -0
  47. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +0 -0
  48. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20191024203106_create_thing_without_history.rb +0 -0
  49. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20221018204220_create_silent_posts.rb +0 -0
  50. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/db/migrate/20221018204255_create_silent_post_histories.rb +0 -0
  51. /data/{spec → Users/brettshollenberger/programming/historiographer/spec}/factories/post.rb +0 -0
@@ -0,0 +1,298 @@
1
+ # Historiographer
2
+
3
+ Losing data sucks. Every time you update or destroy a record in Rails, you lose the old data.
4
+
5
+ Historiographer fixes this problem in a better way than existing auditing gems.
6
+
7
+ ## Existing auditing gems for Rails suck
8
+
9
+ The Audited gem has some serious flaws.
10
+
11
+ 1. The `versions` table quickly grows too large to query
12
+
13
+ 2. It doesn't provide the indexes you need from your primary tables
14
+
15
+ 3. It doesn't provdie out-of-the-box snapshots
16
+
17
+ ## How does Historiographer solve these problems?
18
+
19
+ Historiographer introduces the concept of _history tables:_ append-only tables that have the same structure and indexes as your primary table.
20
+
21
+ If you have a `posts` table:
22
+
23
+ | id | title |
24
+ | :-- | :------------- |
25
+ | 1 | My Great Post |
26
+ | 2 | My Second Post |
27
+
28
+ You'll also have a `post_histories_table`:
29
+
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 |
34
+
35
+ If you change the title of the 1st post:
36
+
37
+ `Post.find(1).update(title: "Title With Better SEO", history_user_id: current_user.id)`
38
+
39
+ You'll expect your `posts` table to be updated directly:
40
+
41
+ | id | title |
42
+ | :-- | :-------------------- |
43
+ | 1 | Title With Better SEO |
44
+ | 2 | My Second Post |
45
+
46
+ But also, your `histories` table will be updated:
47
+
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 |
53
+
54
+ A few things have happened here:
55
+
56
+ 1. The primary table (`posts`) is updated directly
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"
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.
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.
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
+
133
+ # Getting Started
134
+
135
+ Whenever you include the `Historiographer` gem in your ActiveRecord model, it allows you to insert, update, or delete data as you normally would.
136
+
137
+ ```ruby
138
+ class Post < ActiveRecord::Base
139
+ include Historiographer
140
+ end
141
+ ```
142
+
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:
151
+
152
+ ```ruby
153
+ # In an initializer
154
+ Historiographer::Configuration.mode = :histories # Default mode
155
+ # or
156
+ Historiographer::Configuration.mode = :snapshot_only
157
+ ```
158
+
159
+ Or per model using `historiographer_mode`:
160
+
161
+ ```ruby
162
+ class Post < ActiveRecord::Base
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)
170
+ end
171
+ ```
172
+
173
+ ## Create A Migration
174
+
175
+ You need a separate table to store histories for each model.
176
+
177
+ So if you have a Posts model:
178
+
179
+ ```ruby
180
+ class CreatePosts < ActiveRecord::Migration
181
+ def change
182
+ create_table :posts do |t|
183
+ t.string :title, null: false
184
+ t.boolean :enabled
185
+ end
186
+ add_index :posts, :enabled
187
+ end
188
+ end
189
+ ```
190
+
191
+ You should create a model named _posts_histories_:
192
+
193
+ ```ruby
194
+ require "historiographer/postgres_migration"
195
+ class CreatePostHistories < ActiveRecord::Migration
196
+ def change
197
+ create_table :post_histories do |t|
198
+ t.histories
199
+ end
200
+ end
201
+ end
202
+ ```
203
+
204
+ The `t.histories` method will automatically create a table with the following columns:
205
+
206
+ - `id` (because every model has a primary key)
207
+ - `post_id` (because this is the foreign key)
208
+ - `title` (because it was on the original model)
209
+ - `enabled` (because it was on the original model)
210
+ - `history_started_at` (to denote when this history became the canonical version)
211
+ - `history_ended_at` (to denote when this history was no longer the canonical version, if it has stopped being the canonical version)
212
+ - `history_user_id` (to denote the user that made this change, if one is known)
213
+
214
+ Additionally it will add indices on:
215
+
216
+ - The same columns that had indices on the original model (e.g. `enabled`)
217
+ - `history_started_at`, `history_ended_at`, and `history_user_id`
218
+
219
+ ## Models
220
+
221
+ The primary model should include `Historiographer`:
222
+
223
+ ```ruby
224
+ class Post < ActiveRecord::Base
225
+ include Historiographer
226
+ end
227
+ ```
228
+
229
+ You should also make a `PostHistory` class if you're going to query `PostHistory` from Rails:
230
+
231
+ ```ruby
232
+ class PostHistory < ActiveRecord::Base
233
+ end
234
+ ```
235
+
236
+ The `Posts` class will acquire a `histories` method, and the `PostHistory` model will gain a `post` method:
237
+
238
+ ```ruby
239
+ p = Post.first
240
+ p.histories.first.class
241
+
242
+ # => "PostHistory"
243
+
244
+ p.histories.first.post == p
245
+ # => true
246
+ ```
247
+
248
+ ## Creating, Updating, and Destroying Data:
249
+
250
+ You can just use normal ActiveRecord methods, and all will record histories:
251
+
252
+ ```ruby
253
+ Post.create(title: "My Great Title", history_user_id: current_user.id)
254
+ Post.find_by(title: "My Great Title").update(title: "A New Title", history_user_id: current_user.id)
255
+ Post.update_all(title: "They're all the same!", history_user_id: current_user.id)
256
+ Post.last.destroy!(history_user_id: current_user.id)
257
+ Post.destroy_all(history_user_id: current_user.id)
258
+ ```
259
+
260
+ The `histories` classes have a `current` method, which only finds current history records. These records will also be the same as the data in the primary table.
261
+
262
+ ```ruby
263
+ p = Post.first
264
+ p.current_history
265
+
266
+ PostHistory.current
267
+ ```
268
+
269
+ ### What to do when generated index names are too long
270
+
271
+ Sometimes the generated index names are too long. Just like with standard Rails migrations, you can override the name of the index to fix this problem. To do so, use the `index_names` argument to override individual index names:
272
+
273
+ ```ruby
274
+ require "historiographer/postgres_migration"
275
+ class CreatePostHistories < ActiveRecord::Migration
276
+ def change
277
+ create_table :post_histories do |t|
278
+ t.histories index_names: {
279
+ title: "my_index_name",
280
+ [:compound, :index] => "my_compound_index_name"
281
+ }
282
+ end
283
+ end
284
+ end
285
+ ```
286
+
287
+ == Mysql Install
288
+
289
+ For contributors on OSX, you may have difficulty installing mysql:
290
+
291
+ ```
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
293
+ ```
294
+
295
+ == Copyright
296
+
297
+ Copyright (c) 2016-2020 brettshollenberger. See LICENSE.txt for
298
+ further details.
@@ -2,67 +2,26 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: historiographer 3.1.2 ruby lib
5
+ # stub: historiographer 4.0.0 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "historiographer".freeze
9
- s.version = "3.1.2"
9
+ s.version = "4.1.0"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["brettshollenberger".freeze]
14
- s.date = "2022-10-19"
14
+ s.date = "2023-08-22"
15
15
  s.description = "Creates separate tables for each history table".freeze
16
16
  s.email = "brett.shollenberger@gmail.com".freeze
17
17
  s.extra_rdoc_files = [
18
18
  "LICENSE.txt",
19
19
  "README.md"
20
20
  ]
21
- s.files = [
22
- ".document",
23
- ".rspec",
24
- ".ruby-version",
25
- ".standalone_migrations",
26
- "Gemfile",
27
- "Gemfile.lock",
28
- "Guardfile",
29
- "LICENSE.txt",
30
- "README.md",
31
- "Rakefile",
32
- "VERSION",
33
- "historiographer.gemspec",
34
- "init.rb",
35
- "lib/historiographer.rb",
36
- "lib/historiographer/history.rb",
37
- "lib/historiographer/history_migration.rb",
38
- "lib/historiographer/history_migration_mysql.rb",
39
- "lib/historiographer/mysql_migration.rb",
40
- "lib/historiographer/postgres_migration.rb",
41
- "lib/historiographer/relation.rb",
42
- "lib/historiographer/safe.rb",
43
- "lib/historiographer/silent.rb",
44
- "spec/db/database.yml",
45
- "spec/db/migrate/20161121212228_create_posts.rb",
46
- "spec/db/migrate/20161121212229_create_post_histories.rb",
47
- "spec/db/migrate/20161121212230_create_authors.rb",
48
- "spec/db/migrate/20161121212231_create_author_histories.rb",
49
- "spec/db/migrate/20161121212232_create_users.rb",
50
- "spec/db/migrate/20171011194624_create_safe_posts.rb",
51
- "spec/db/migrate/20171011194715_create_safe_post_histories.rb",
52
- "spec/db/migrate/20191024142304_create_thing_with_compound_index.rb",
53
- "spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb",
54
- "spec/db/migrate/20191024203106_create_thing_without_history.rb",
55
- "spec/db/migrate/20221018204220_create_silent_posts.rb",
56
- "spec/db/migrate/20221018204255_create_silent_post_histories.rb",
57
- "spec/db/schema.rb",
58
- "spec/examples.txt",
59
- "spec/factories/post.rb",
60
- "spec/historiographer_spec.rb",
61
- "spec/spec_helper.rb"
62
- ]
21
+ s.files = Dir[File.expand_path("**/*")]
63
22
  s.homepage = "http://github.com/brettshollenberger/historiographer".freeze
64
23
  s.licenses = ["MIT".freeze]
65
- s.rubygems_version = "3.2.23".freeze
24
+ s.rubygems_version = "3.2.22".freeze
66
25
  s.summary = "Create histories of your ActiveRecord tables".freeze
67
26
 
68
27
  if s.respond_to? :specification_version then
@@ -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,17 +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
- send(column.type, column.name, opts.symbolize_keys!)
39
+ if RUBY_VERSION.to_i >= 3
40
+ send(column.type, column.name, **opts)
41
+ else
42
+ send(column.type, column.name, opts)
43
+ end
38
44
  end
39
45
 
40
46
  datetime :history_started_at, null: false
41
47
  datetime :history_ended_at
42
48
  integer :history_user_id
49
+ string :snapshot_id
43
50
 
44
51
  indices_sql = %q(
45
52
  SELECT
@@ -70,7 +77,8 @@ module Historiographer
70
77
  foreign_key,
71
78
  :history_started_at,
72
79
  :history_ended_at,
73
- :history_user_id
80
+ :history_user_id,
81
+ :snapshot_id
74
82
  ])
75
83
 
76
84
  indexes.each do |index_definition|
@@ -86,4 +94,4 @@ module Historiographer
86
94
 
87
95
  end
88
96
  end
89
- end
97
+ end
@@ -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