historiographer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ require 'active_support/concern'
2
+
3
+ #
4
+ # See Historiographer for more details
5
+ #
6
+ # Historiographer::History is a mixin that is
7
+ # automatically included in any History class (e.g. RetailerProductHistory).
8
+ #
9
+ # A History record represents a snapshot of a primary record at a particular point
10
+ # in time.
11
+ #
12
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
13
+ #
14
+ # E.g. You have a RetailerProduct (ID: 1) that makes the following changes:
15
+ #
16
+ # 1) rp = RetailerProduct.create(name: "Sabra")
17
+ #
18
+ # 2) rp.update(name: "Sabra Hummus")
19
+ #
20
+ # 3) rp.update(name: "Sabra Pine Nut Hummus")
21
+ #
22
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
23
+ #
24
+ # Your RetailerProduct record looks like this:
25
+ #
26
+ # <#RetailerProduct:0x007fbf00c78f00 name: "Sabra Pine Nut Hummus">
27
+ #
28
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
29
+ #
30
+ # But your RetailerProductHistories look like this:
31
+ #
32
+ # rp.histories
33
+ #
34
+ # <#RetailerProductHistory:0x007fbf00c78f01 name: "Sabra", history_started_at: 1.minute.ago, history_ended_at: 30.seconds.ago>
35
+ # <#RetailerProductHistory:0x007fbf00c78f02 name: "Sabra Hummus", history_started_at: 30.seconds.ago, history_ended_at: 10.seconds.ago>
36
+ # <#RetailerProductHistory:0x007fbf00c78f03 name: "Sabra Pine Nut Hummus", history_started_at: 10.seconds.ago, history_ended_at: nil>
37
+ #
38
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
39
+ #
40
+ # Since these Histories are intended to represent a snapshot in time, they should never be
41
+ # deleted or modified directly. Historiographer will manage all of the nuances for you.
42
+ #
43
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
44
+ #
45
+ # Your classes should be written like this:
46
+ #
47
+ # class RetailerProduct < ActiveRecord::Base
48
+ # include Historiographer
49
+ # end
50
+ #
51
+ # # This class is created automatically. You don't
52
+ # # need to create a file yourself, unless you
53
+ # # want to add additional methods.
54
+ # #
55
+ # class RetailerProductHistory < ActiveRecord::Base
56
+ # include Historiographer::History
57
+ # end
58
+ #
59
+ module Historiographer
60
+ module History
61
+ extend ActiveSupport::Concern
62
+
63
+ included do |base|
64
+
65
+ #
66
+ # A History class (e.g. RetailerProductHistory) will gain
67
+ # access to a current scope, returning
68
+ # the most recent history.
69
+ #
70
+ # Although we never want there to be more than 1 current history,
71
+ # in the off chance this invariant is broken, this method is
72
+ # guaranteed to only return an array containing the most recent history.
73
+ #
74
+ scope :current, -> { where(history_ended_at: nil).order(id: :desc).limit(1) }
75
+
76
+ #
77
+ # A History class will be linked to the user
78
+ # that made the changes.
79
+ #
80
+ # E.g.
81
+ #
82
+ # RetailerProductHistory.first.user
83
+ #
84
+ # To use histories, a user class must be defined.
85
+ #
86
+ belongs_to :user, foreign_key: :history_user_id
87
+
88
+ #
89
+ # Historiographer is opinionated about how History classes
90
+ # should be named.
91
+ #
92
+ # For a class named "RetailerProductHistory", the History class should be named
93
+ # "RetailerProductHistory."
94
+ #
95
+ foreign_class_name = base.name.gsub(/History$/) {} # e.g. "RetailerProductHistory" => "RetailerProduct"
96
+ association_name = foreign_class_name.underscore.to_sym # e.g. "RetailerProduct" => :retailer_product
97
+
98
+ #
99
+ # Historiographer will automatically setup the association
100
+ # to the primary class (e.g. RetailerProduct)
101
+ #
102
+ # If the History class has already defined this association, raise
103
+ # an error, because we don't yet see any reason why end users
104
+ # should be allowed to override this method.
105
+ #
106
+ # At some point, we may decide to allow this, but for now, we don't
107
+ # know what the requirements/use cases would be.
108
+ #
109
+ # e.g.
110
+ #
111
+ # if RetailerProductHistory.respond_to?(:retailer_product)
112
+ # raise "RetailerProductHistory already has #retailer_product association. Talk to Brett if this is a legit use case"
113
+ # else
114
+ # belongs_to :retailer_product, class_name: RetailerProduct
115
+ # end
116
+ #
117
+ if base.respond_to?(association_name)
118
+ raise "#{base} already has ##{association_name} association. Talk to Brett if this is a legit use case."
119
+ else
120
+ belongs_to association_name, class_name: foreign_class_name
121
+ end
122
+
123
+ #
124
+ # A History record should never be destroyed.
125
+ #
126
+ # History records are immutable, so we enforce
127
+ # this constraint as much as we can at the Rails layer.
128
+ #
129
+ def destroy
130
+ false
131
+ end
132
+
133
+ def destroy!
134
+ false
135
+ end
136
+
137
+ #
138
+ # History records should never be updated, except to set
139
+ # history_ended_at (when they are overridden by future histories).
140
+ #
141
+ # If the record was already persisted, then they only change it
142
+ # is allowed to make is to history_ended_at.
143
+ #
144
+ # If the record was not already persisted, proceed as normal.
145
+ #
146
+ def save(*args)
147
+ if persisted? && (changes.keys - %w(history_ended_at)).any?
148
+ false
149
+ else
150
+ super
151
+ end
152
+ end
153
+
154
+ def save!(*args)
155
+ if persisted? && (changes.keys - %w(history_ended_at)).any?
156
+ false
157
+ else
158
+ super
159
+ end
160
+ end
161
+ end
162
+
163
+ class_methods do
164
+
165
+ #
166
+ # The foreign key to the primary class.
167
+ #
168
+ # E.g. PostHistory.history_foreign_key => post_id
169
+ #
170
+ def history_foreign_key
171
+ name.gsub(/History$/) {}.foreign_key
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,78 @@
1
+ module Historiographer
2
+ module HistoryMigration
3
+ #
4
+ # class CreateAdGroupHistories < ActiveRecord::Migration
5
+ # def change
6
+ # create_table :ad_group_histories do |t|
7
+ # t.histories
8
+ # end
9
+ # end
10
+ # end
11
+ #
12
+ # t.histories(except: ["name"]) # don't include name column
13
+ # t.histories(only: ["name"]) # only include name column
14
+ # t.histories(no_business_columns: true) # only add history timestamps and history_user_id; manually add your own columns
15
+ #
16
+ # Will automatically add user_id, history_started_at,
17
+ # and history_ended_at columns
18
+ #
19
+ def histories(except: [], only: [], no_business_columns: false)
20
+ original_table_name = self.name.gsub(/_histories$/) {}.pluralize
21
+ foreign_key = original_table_name.singularize.foreign_key
22
+
23
+ class_definer = Class.new(ActiveRecord::Base) do
24
+ end
25
+
26
+ class_name = original_table_name.classify
27
+ klass = Object.const_set(class_name, class_definer)
28
+ original_columns = klass.columns.reject { |c| c.name == "id" || except.include?(c.name) || (only.any? && only.exclude?(c.name)) || no_business_columns }
29
+
30
+ integer foreign_key.to_sym, null: false
31
+
32
+ original_columns.each do |column|
33
+ opts = {}
34
+ opts.merge!(column.as_json.clone)
35
+ # opts.merge!(column.type.as_json.clone)
36
+
37
+ send(column.type, column.name, opts.symbolize_keys!)
38
+ end
39
+
40
+ datetime :history_started_at, null: false
41
+ datetime :history_ended_at
42
+ integer :history_user_id
43
+
44
+ index :history_started_at
45
+ index :history_ended_at
46
+ index :history_user_id
47
+ index foreign_key
48
+
49
+ indices_sql = <<-SQL
50
+ SELECT
51
+ a.attname AS column_name
52
+ FROM
53
+ pg_class t,
54
+ pg_class i,
55
+ pg_index ix,
56
+ pg_attribute a
57
+ WHERE
58
+ t.oid = ix.indrelid
59
+ AND i.oid = ix.indexrelid
60
+ AND a.attrelid = t.oid
61
+ AND a.attnum = ANY(ix.indkey)
62
+ AND t.relkind = 'r'
63
+ AND t.relname = ?
64
+ ORDER BY
65
+ t.relname,
66
+ i.relname;
67
+ SQL
68
+
69
+ indices_query_array = [indices_sql, original_table_name]
70
+ indices_sanitized_query = klass.send(:sanitize_sql_array, indices_query_array)
71
+
72
+ klass.connection.execute(indices_sanitized_query).to_a.map(&:values).flatten.reject { |i| i == "id" }.each do |index_name|
73
+ index index_name.to_sym
74
+ end
75
+
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,54 @@
1
+ module Historiographer
2
+ module HistoryMigrationMysql
3
+ #
4
+ # class CreateAdGroupHistories < ActiveRecord::Migration
5
+ # def change
6
+ # create_table :ad_group_histories do |t|
7
+ # t.histories
8
+ # end
9
+ # end
10
+ # end
11
+ #
12
+ # t.histories(except: ["name"]) # don't include name column
13
+ # t.histories(only: ["name"]) # only include name column
14
+ # t.histories(no_business_columns: true) # only add history timestamps and history_user_id; manually add your own columns
15
+ #
16
+ # Will automatically add user_id, history_started_at,
17
+ # and history_ended_at columns
18
+ #
19
+ def histories(except: [], only: [], no_business_columns: false)
20
+ original_table_name = self.name.gsub(/_histories$/) {}.pluralize
21
+ foreign_key = original_table_name.singularize.foreign_key
22
+
23
+ class_definer = Class.new(ActiveRecord::Base) do
24
+ end
25
+
26
+ class_name = original_table_name.classify
27
+ klass = Object.const_set(class_name, class_definer)
28
+ original_columns = klass.columns.reject { |c| c.name == "id" || except.include?(c.name) || (only.any? && only.exclude?(c.name)) || no_business_columns }
29
+
30
+ integer foreign_key.to_sym, null: false
31
+
32
+ original_columns.each do |column|
33
+ opts = {}
34
+ opts.merge!(column.as_json.clone)
35
+
36
+ send(column.type, column.name, opts.symbolize_keys!)
37
+ end
38
+
39
+ datetime :history_started_at, null: false
40
+ datetime :history_ended_at
41
+ integer :history_user_id
42
+
43
+ index :history_started_at
44
+ index :history_ended_at
45
+ index :history_user_id
46
+ index foreign_key
47
+
48
+ ActiveRecord::Base.connection.indexes(original_table_name).each do |index|
49
+ index index.columns
50
+ end
51
+
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "history_migration_mysql"
2
+
3
+ if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
4
+ class ActiveRecord::ConnectionAdapters::TableDefinition
5
+ include Historiographer::HistoryMigrationMysql
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "history_migration"
2
+
3
+ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition)
4
+ class ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition
5
+ include Historiographer::HistoryMigration
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # Historiographer::Safe is intended to be used to migrate an existing model
2
+ # to Historiographer, not as a long-term solution.
3
+ #
4
+ # Historiographer will throw an error if a model is saved without a user present,
5
+ # unless you explicitly call save_without_history.
6
+ #
7
+ # Historiographer::Safe will not throw an error, but will rather produce a Rollbar,
8
+ # which enables a programmer to find all locations that need to be migrated,
9
+ # rather than allowing an unsafe migration to take place.
10
+ #
11
+ # Eventually the programmer is expected to replace Safe with Historiographer so
12
+ # that future programmers will get an error if they try to save without user_id.
13
+ #
14
+ module Historiographer
15
+ module Safe
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ include Historiographer
20
+
21
+ def should_validate_history_user_id_present?
22
+ false
23
+ end
24
+
25
+ private
26
+
27
+ def history_user_absent_action
28
+ Rollbar.error("history_user_id must be passed in order to save record with histories! If you are in a context with no history_user_id, explicitly call #save_without_history")
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,235 @@
1
+ require "active_support/all"
2
+ require_relative "./historiographer/history"
3
+ require_relative "./historiographer/postgres_migration"
4
+ require_relative "./historiographer/safe"
5
+
6
+ # Historiographer takes "histories" (think audits or snapshots) of your model whenever you make changes.
7
+ #
8
+ # Core business data stored in histories can never be changed or destroyed (at least not from Rails-land), offering you
9
+ # a little more peace of mind (just a little).
10
+ #
11
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
12
+ #
13
+ # A little example:
14
+ #
15
+ # photo = Photo.create(file: "cool.jpg")
16
+ # photo.histories # => [ <#PhotoHistory file: cool.jpg > ]
17
+ #
18
+ # photo.file = "fun.jpg"
19
+ # photo.save!
20
+ # photo.histories.reload # => [ <#PhotoHistory file: cool.jpg >, <#PhotoHistory file: fun.jpg> ]
21
+ #
22
+ # photo.histories.last.destroy! # => false
23
+ #
24
+ # photo.histories.last.update!(file: "bad.jpg") # => false
25
+ #
26
+ # photo.histories.last.file = "bad.jpg"
27
+ # photo.histories.last.save! # => false
28
+ #
29
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
30
+ #
31
+ # How to use:
32
+ #
33
+ # 1) Add historiographer to your apps dependencies folder
34
+ #
35
+ # Ex: sudo ln -s ../../../shared/historiographer historiographer
36
+ #
37
+ # 2) Add historiographer to your apps gem file and bundle
38
+ #
39
+ # gem 'historiographer', path: 'dependencies/historiographer', require: 'historiographer'
40
+ #
41
+ # 3) Create a primary table
42
+ #
43
+ # create_table :photos do |t|
44
+ # t.string :file
45
+ # end
46
+ #
47
+ # 4) Create history table. 't.histories' pulls in all the primary tables attributes plus a few required by historiographer.
48
+ #
49
+ # require "historiographer/postgres_migration"
50
+ # class CreatePhotoHistories < ActiveRecord::Migration
51
+ # def change
52
+ # create_table :photo_histories do |t|
53
+ # t.histories
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # 5) Include Historiographer in the primary class:
59
+ #
60
+ # class Photo < ActiveRecord::Base
61
+ # include Historiographer
62
+ # end
63
+ #
64
+ # 6) Create a history class
65
+ #
66
+ # class PhotoHistory < ActiveRecord::Base (or whereever your app inherits from. Ex CPG: CpgConnection, Shoppers: ShoppersRecord, etc)
67
+ # end
68
+ #
69
+ # 7) Enjoy!
70
+ #
71
+ module Historiographer
72
+ extend ActiveSupport::Concern
73
+
74
+ class HistoryUserIdMissingError < StandardError; end
75
+
76
+ UTC = Time.now.in_time_zone("UTC").time_zone
77
+
78
+ included do |base|
79
+ after_save :record_history, if: :should_record_history?
80
+ validate :validate_history_user_id_present, if: :should_validate_history_user_id_present?
81
+
82
+ def should_validate_history_user_id_present?
83
+ true
84
+ end
85
+
86
+ def validate_history_user_id_present
87
+ if @no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer))
88
+ errors.add(:history_user_id, "must be an integer")
89
+ end
90
+ end
91
+
92
+ def assign_attributes(new_attributes)
93
+ huid = new_attributes[:history_user_id]
94
+
95
+ if huid.present?
96
+ self.class.nested_attributes_options.each do |association, _|
97
+ reflection = self.class.reflect_on_association(association)
98
+ assoc_attrs = new_attributes["#{association}_attributes"]
99
+
100
+ if assoc_attrs.present?
101
+ if reflection.collection?
102
+ assoc_attrs.values.each do |hash|
103
+ hash.merge!(history_user_id: huid)
104
+ end
105
+ else
106
+ assoc_attrs.merge!(history_user_id: huid)
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ super
113
+ end
114
+
115
+ def historiographer_changes?
116
+ case Rails.version.to_f
117
+ when 0..5 then changed? && valid?
118
+ when 5.1..6 then saved_changes?
119
+ else
120
+ raise "Unsupported Rails version"
121
+ end
122
+ end
123
+ #
124
+ # If there are any changes, and the model is valid,
125
+ # and we're not force-overriding history recording,
126
+ # then record history after successful save.
127
+ #
128
+ def should_record_history?
129
+ historiographer_changes? && !@no_history
130
+ end
131
+
132
+ attr_accessor :history_user_id
133
+
134
+ class_name = "#{base.name}History"
135
+
136
+ if base.respond_to?(:histories)
137
+ raise "#{base} already has histories. Talk to Brett if this is a legit use case."
138
+ else
139
+ has_many :histories, class_name: class_name
140
+ has_one :current_history, -> { current }, class_name: class_name
141
+ end
142
+
143
+ begin
144
+ class_name.constantize
145
+ rescue
146
+ history_class_initializer = Class.new(ActiveRecord::Base) do
147
+ end
148
+
149
+ Object.const_set(class_name, history_class_initializer)
150
+ end
151
+
152
+ klass = class_name.constantize
153
+
154
+ klass.send(:include, Historiographer::History) unless klass.ancestors.include?(Historiographer::History)
155
+
156
+ #
157
+ # The acts_as_paranoid gem, which we tend to use with our History classes,
158
+ # uses update_columns to update deleted_at fields.
159
+ #
160
+ # In order to make sure these changes are persisted into Histories objects,
161
+ # we also have to record history here.
162
+ #
163
+ module UpdateColumnsWithHistory
164
+ def update_columns(*args)
165
+ opts = args.extract_options!
166
+ any_changes = opts.keys.reject { |k| k == "id" }.any?
167
+
168
+ transaction do
169
+ persisted = super(opts)
170
+
171
+ if any_changes && persisted
172
+ record_history
173
+ end
174
+ end
175
+ end
176
+ end
177
+ base.send(:prepend, UpdateColumnsWithHistory)
178
+
179
+ def save_without_history(*args, &block)
180
+ @no_history = true
181
+ save(*args, &block)
182
+ @no_history = false
183
+ end
184
+
185
+ def save_without_history!(*args, &block)
186
+ @no_history = true
187
+ save!(*args, &block)
188
+ @no_history = false
189
+ end
190
+
191
+ private
192
+
193
+ def history_user_absent_action
194
+ raise HistoryUserIdMissingError.new("history_user_id must be passed in order to save record with histories! If you are in a context with no history_user_id, explicitly call #save_without_user")
195
+ end
196
+
197
+ #
198
+ # Save a record of the most recent changes, with the current
199
+ # time as history_started_at, and the provided user as history_user_id.
200
+ #
201
+ # Find the most recent history, and update its history_ended_at timestamp
202
+ #
203
+ def record_history
204
+ history_user_absent_action if history_user_id.nil?
205
+
206
+ attrs = attributes.clone
207
+ history_class = self.class.history_class
208
+ foreign_key = history_class.history_foreign_key
209
+
210
+ now = UTC.now
211
+ attrs.merge!(foreign_key => attrs["id"], history_started_at: now, history_user_id: history_user_id)
212
+
213
+ attrs = attrs.except("id")
214
+
215
+ current_history = histories.where(history_ended_at: nil).order("id desc").limit(1).last
216
+
217
+ unless foreign_key.present? && history_class.present?
218
+ raise "Need foreign key and history class to save history!"
219
+ else
220
+ history_class.create!(attrs)
221
+ current_history.update!(history_ended_at: now) if current_history.present?
222
+ end
223
+ end
224
+ end
225
+
226
+ class_methods do
227
+
228
+ #
229
+ # E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory
230
+ #
231
+ def history_class
232
+ "#{name}History".constantize
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,27 @@
1
+ # default: &default
2
+ # adapter: postgresql
3
+ # prepared_statements: false
4
+ # url: "postgres://localhost/historiographer_development"
5
+
6
+ # development:
7
+ # <<: *default
8
+
9
+ # test:
10
+ # adapter: postgresql
11
+ # database: historiographer_test
12
+
13
+ mysql_default: &mysql_default
14
+ adapter: mysql2
15
+ encoding: utf8
16
+ username: root
17
+ password:
18
+ host: 127.0.0.1
19
+ port: 3306
20
+
21
+ development:
22
+ <<: *mysql_default
23
+ database: historiographer_development
24
+
25
+ test:
26
+ <<: *mysql_default
27
+ database: historiographer_test
@@ -0,0 +1,19 @@
1
+ class CreatePosts < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :posts do |t|
4
+ t.string :title, null: false
5
+ t.text :body, null: false
6
+ t.integer :author_id, null: false
7
+ t.boolean :enabled, default: false
8
+ t.datetime :live_at
9
+ t.datetime :deleted_at
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :posts, :author_id
15
+ add_index :posts, :enabled
16
+ add_index :posts, :live_at
17
+ add_index :posts, :deleted_at
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ require "historiographer/postgres_migration"
2
+ require "historiographer/mysql_migration"
3
+
4
+ class CreatePostHistories < ActiveRecord::Migration[5.1]
5
+ def change
6
+ create_table :post_histories do |t|
7
+ t.histories
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ class CreateAuthors < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :authors do |t|
4
+ t.string :full_name, null: false
5
+ t.text :bio
6
+ t.datetime :deleted_at
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :authors, :deleted_at
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ require "historiographer/postgres_migration"
2
+ require "historiographer/mysql_migration"
3
+
4
+ class CreateAuthorHistories < ActiveRecord::Migration[5.1]
5
+ def change
6
+ create_table :author_histories do |t|
7
+ t.histories
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ require "historiographer/postgres_migration"
2
+
3
+ class CreateUsers < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :users do |t|
6
+ t.string :name
7
+ end
8
+ end
9
+ end