historiographer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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