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