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.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/.standalone_migrations +6 -0
- data/Gemfile +29 -0
- data/Gemfile.lock +196 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +20 -0
- data/README.md +124 -0
- data/Rakefile +54 -0
- data/VERSION +1 -0
- data/historiographer.gemspec +108 -0
- data/init.rb +18 -0
- data/lib/historiographer/history.rb +175 -0
- data/lib/historiographer/history_migration.rb +78 -0
- data/lib/historiographer/history_migration_mysql.rb +54 -0
- data/lib/historiographer/mysql_migration.rb +7 -0
- data/lib/historiographer/postgres_migration.rb +7 -0
- data/lib/historiographer/safe.rb +33 -0
- data/lib/historiographer.rb +235 -0
- data/spec/db/database.yml +27 -0
- data/spec/db/migrate/20161121212228_create_posts.rb +19 -0
- data/spec/db/migrate/20161121212229_create_post_histories.rb +10 -0
- data/spec/db/migrate/20161121212230_create_authors.rb +13 -0
- data/spec/db/migrate/20161121212231_create_author_histories.rb +10 -0
- data/spec/db/migrate/20161121212232_create_users.rb +9 -0
- data/spec/db/migrate/20171011194624_create_safe_posts.rb +19 -0
- data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +9 -0
- data/spec/db/schema.rb +121 -0
- data/spec/examples.txt +21 -0
- data/spec/historiographer_spec.rb +395 -0
- data/spec/spec_helper.rb +40 -0
- metadata +258 -0
@@ -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,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
|