time-travel 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/MIT-LICENSE +20 -0
- data/README.md +293 -0
- data/Rakefile +29 -0
- data/lib/generators/time_travel/USAGE +9 -0
- data/lib/generators/time_travel/templates/time_travel_migration_existing.rb.erb +17 -0
- data/lib/generators/time_travel/templates/time_travel_migration_new.rb.erb +19 -0
- data/lib/generators/time_travel/time_travel_generator.rb +49 -0
- data/lib/tasks/create_postgres_function.rake +6 -0
- data/lib/time_travel/configuration.rb +9 -0
- data/lib/time_travel/railtie.rb +7 -0
- data/lib/time_travel/sql_function_helper.rb +19 -0
- data/lib/time_travel/timeline.rb +225 -0
- data/lib/time_travel/timeline_helper.rb +105 -0
- data/lib/time_travel/update_helper.rb +72 -0
- data/lib/time_travel/version.rb +3 -0
- data/lib/time_travel.rb +22 -0
- data/lib/time_travel_backup.rb +279 -0
- data/sql/create_column_value.sql +67 -0
- data/sql/get_json_attrs.sql +36 -0
- data/sql/update_bulk_history.sql +68 -0
- data/sql/update_history.sql +209 -0
- data/sql/update_latest.sql +94 -0
- metadata +122 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
module TimeTravel
|
2
|
+
class SqlFunctionHelper
|
3
|
+
def self.create(schema=nil)
|
4
|
+
connection = ActiveRecord::Base.connection
|
5
|
+
gem_root = File.expand_path('../../../', __FILE__)
|
6
|
+
ActiveRecord::Base.transaction do
|
7
|
+
result = connection.execute("SHOW search_path;")
|
8
|
+
if schema && !result.first["search_path"].eql?(schema)
|
9
|
+
connection.execute "SET search_path TO #{schema};"
|
10
|
+
end
|
11
|
+
connection.execute(IO.read(gem_root + "/sql/create_column_value.sql"))
|
12
|
+
connection.execute(IO.read(gem_root + "/sql/get_json_attrs.sql"))
|
13
|
+
connection.execute(IO.read(gem_root + "/sql/update_history.sql"))
|
14
|
+
connection.execute(IO.read(gem_root + "/sql/update_bulk_history.sql"))
|
15
|
+
connection.execute(IO.read(gem_root + "/sql/update_latest.sql"))
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
require "time_travel/sql_function_helper"
|
2
|
+
require "time_travel/update_helper"
|
3
|
+
require "pp"
|
4
|
+
|
5
|
+
class Timeline
|
6
|
+
include UpdateHelper
|
7
|
+
UPDATE_MODE=ENV["TIME_TRAVEL_UPDATE_MODE"] || TimeTravel.configuration.update_mode
|
8
|
+
|
9
|
+
def initialize(model_class,**timeline_identifiers)
|
10
|
+
@model_class=model_class
|
11
|
+
@timeline_identifiers=timeline_identifiers
|
12
|
+
@timeline=model_class.where(**timeline_identifiers)
|
13
|
+
end
|
14
|
+
|
15
|
+
def at(date, as_of: Time.current)
|
16
|
+
record=@timeline
|
17
|
+
.where("effective_from <= ?", date)
|
18
|
+
.where("effective_till > ?", date)
|
19
|
+
.where("valid_from <= ?", as_of)
|
20
|
+
.where("valid_till > ?", as_of)
|
21
|
+
record.first if record.exists?
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
def full_history
|
26
|
+
@timeline.order("effective_from ASC").order("valid_from ASC")
|
27
|
+
end
|
28
|
+
|
29
|
+
def as_of(valid_date=Time.current)
|
30
|
+
@timeline
|
31
|
+
.where("valid_from <= ?", valid_date)
|
32
|
+
.where("valid_till > ?", valid_date)
|
33
|
+
.order("effective_from ASC")
|
34
|
+
end
|
35
|
+
|
36
|
+
def valid_history(effective_at: Time.current)
|
37
|
+
@timeline
|
38
|
+
.where("effective_from <= ?", effective_date)
|
39
|
+
.where("effective_till > ?", effective_date)
|
40
|
+
.order("valid_from ASC")
|
41
|
+
end
|
42
|
+
|
43
|
+
def effective_history
|
44
|
+
@timeline.where(valid_till: TimeTravel::INFINITE_DATE).order("effective_from ASC")
|
45
|
+
end
|
46
|
+
|
47
|
+
def construct_record(attributes,current_time:,effective_from:,effective_till:)
|
48
|
+
record=@model_class.new
|
49
|
+
record.attributes=attributes
|
50
|
+
@timeline_identifiers.each do |attribute,value|
|
51
|
+
record[attribute]=value
|
52
|
+
end
|
53
|
+
record.current_time=current_time
|
54
|
+
record.effective_from=effective_from
|
55
|
+
record.effective_till=effective_till
|
56
|
+
record
|
57
|
+
end
|
58
|
+
|
59
|
+
def create_or_update(attributes,current_time: Time.current, effective_from: nil, effective_till: nil)
|
60
|
+
if self.has_history?
|
61
|
+
self.update(
|
62
|
+
attributes, current_time: current_time, effective_from: effective_from, effective_till: effective_till)
|
63
|
+
else
|
64
|
+
self.create(
|
65
|
+
attributes,current_time: current_time, effective_from: effective_from, effective_till: effective_till)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def create(attributes,current_time: Time.current, effective_from: nil, effective_till: nil)
|
70
|
+
record=construct_record(attributes, current_time: current_time,
|
71
|
+
effective_from: effective_from, effective_till: effective_till)
|
72
|
+
if self.has_history?
|
73
|
+
raise "timeline already exists"
|
74
|
+
end
|
75
|
+
raise ActiveRecord::RecordInvalid.new(record) unless record.validate_update(attributes)
|
76
|
+
record.save!
|
77
|
+
record
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.bulk_update(model_class, attribute_set, current_time: Time.current, latest_transactions: false)
|
81
|
+
if UPDATE_MODE=="native"
|
82
|
+
attribute_set.each do |attributes|
|
83
|
+
attributes.symbolize_keys!
|
84
|
+
if attributes.slice(*model_class.timeline_fields).keys.length != model_class.timeline_fields.length
|
85
|
+
raise "Timeline identifiers can't be empty"
|
86
|
+
end
|
87
|
+
timeline=model_class.timeline(attributes.slice(*model_class.timeline_fields))
|
88
|
+
timeline.create_or_update(
|
89
|
+
attributes,
|
90
|
+
current_time: current_time,
|
91
|
+
effective_from: attributes[:effective_from],
|
92
|
+
effective_till: attributes[:effective_till]
|
93
|
+
)
|
94
|
+
end
|
95
|
+
else
|
96
|
+
update_sql(
|
97
|
+
model_class,
|
98
|
+
attribute_set,
|
99
|
+
current_time: current_time,
|
100
|
+
latest_transactions: latest_transactions
|
101
|
+
)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def update(attributes, current_time: Time.current, effective_from: nil, effective_till: nil)
|
106
|
+
attributes.symbolize_keys!
|
107
|
+
attributes=attributes.except(*ignored_update_attributes)
|
108
|
+
return true if attributes.empty?
|
109
|
+
if not self.has_history?
|
110
|
+
raise "timeline not found"
|
111
|
+
end
|
112
|
+
record=construct_record(
|
113
|
+
attributes, current_time: current_time, effective_from: effective_from, effective_till: effective_till)
|
114
|
+
raise ActiveRecord::RecordInvalid.new(record) unless record.validate_update(attributes)
|
115
|
+
record_attributes=record.attributes.except(*ignored_copy_attributes).symbolize_keys!
|
116
|
+
update_attributes=record_attributes.slice(*attributes.keys)
|
117
|
+
if UPDATE_MODE=="native"
|
118
|
+
update_native(
|
119
|
+
record, update_attributes,
|
120
|
+
current_time: current_time, effective_from: effective_from, effective_till: effective_till
|
121
|
+
)
|
122
|
+
else
|
123
|
+
update_attributes.merge!(@timeline_identifiers)
|
124
|
+
update_attributes.merge!({effective_from: effective_from, effective_till: effective_till})
|
125
|
+
self.class.update_sql(@model_class, [update_attributes], current_time: current_time)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
def update_native(record, update_attributes, current_time: Time.current, effective_from: nil, effective_till: nil)
|
131
|
+
affected_records = fetch_history_for_correction(record)
|
132
|
+
affected_timeframes = get_affected_timeframes(record, affected_records)
|
133
|
+
|
134
|
+
corrected_records = construct_corrected_records(
|
135
|
+
record, affected_timeframes, affected_records, update_attributes)
|
136
|
+
squished_records = squish_record_history(corrected_records)
|
137
|
+
|
138
|
+
@model_class.transaction do
|
139
|
+
squished_records.each do |record|
|
140
|
+
insert_record=record.merge(
|
141
|
+
current_time: current_time
|
142
|
+
)
|
143
|
+
@model_class.create!(insert_record)
|
144
|
+
end
|
145
|
+
|
146
|
+
affected_records.each {|record| record.update_attribute(:valid_till, current_time)}
|
147
|
+
end
|
148
|
+
true
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.update_sql(model_class, attribute_set, current_time: Time.current, latest_transactions: false)
|
152
|
+
other_attrs = (model_class.column_names - ["id", "created_at", "updated_at", "valid_from", "valid_till"])
|
153
|
+
empty_obj_attrs = other_attrs.map{|attr| {attr => nil}}.reduce(:merge!).with_indifferent_access
|
154
|
+
query = ActiveRecord::Base.connection.quote(model_class.unscoped.where(valid_till: TimeTravel::INFINITE_DATE).to_sql)
|
155
|
+
table_name = ActiveRecord::Base.connection.quote(model_class.table_name)
|
156
|
+
|
157
|
+
attribute_set.each_slice(model_class.batch_size).to_a.each do |batched_attribute_set|
|
158
|
+
batched_attribute_set.each do |attrs|
|
159
|
+
attrs.symbolize_keys!
|
160
|
+
set_enum(model_class, attrs)
|
161
|
+
attrs[:timeline_clauses], attrs[:update_attrs] = attrs.partition do |key, value|
|
162
|
+
key.in?(model_class.timeline_fields)
|
163
|
+
end.map(&:to_h).map(&:symbolize_keys!)
|
164
|
+
if attrs[:timeline_clauses].empty? || attrs[:timeline_clauses].values.any?(&:blank?)
|
165
|
+
raise "Timeline identifiers can't be empty"
|
166
|
+
end
|
167
|
+
obj_current_time = attrs[:update_attrs].delete(:current_time) || current_time
|
168
|
+
attrs[:effective_from] = db_timestamp(attrs[:update_attrs].delete(:effective_from) || obj_current_time)
|
169
|
+
attrs[:effective_till] = db_timestamp(attrs[:update_attrs].delete(:effective_till) || TimeTravel::INFINITE_DATE)
|
170
|
+
attrs[:current_time] = db_timestamp(obj_current_time)
|
171
|
+
attrs[:infinite_date] = db_timestamp(TimeTravel::INFINITE_DATE)
|
172
|
+
attrs[:empty_obj_attrs] = empty_obj_attrs.merge(attrs[:timeline_clauses])
|
173
|
+
end
|
174
|
+
attrs = ActiveRecord::Base.connection.quote(batched_attribute_set.to_json)
|
175
|
+
begin
|
176
|
+
result = ActiveRecord::Base.connection.execute("select update_bulk_history(#{query},#{table_name},#{attrs},#{latest_transactions})")
|
177
|
+
rescue => e
|
178
|
+
ActiveRecord::Base.connection.execute 'ROLLBACK'
|
179
|
+
raise e
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def self.set_enum(model_class, attrs)
|
185
|
+
enum_fields, enum_items = model_class.enum_info
|
186
|
+
enum_fields.each do |key|
|
187
|
+
string_value = attrs[key]
|
188
|
+
attrs[key] = enum_items[key][string_value] unless string_value.blank?
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def self.db_timestamp(datetime)
|
193
|
+
datetime.to_datetime.utc.strftime(TimeTravel::PRECISE_TIME_FORMAT)
|
194
|
+
end
|
195
|
+
|
196
|
+
def terminate(current_time: Time.current, effective_till: nil)
|
197
|
+
effective_record = self.effective_history.where(effective_till: TimeTravel::INFINITE_DATE).first
|
198
|
+
if effective_record.present?
|
199
|
+
attributes = effective_record.attributes.except(*ignored_copy_attributes)
|
200
|
+
@model_class.transaction do
|
201
|
+
@model_class.create!(
|
202
|
+
attributes.merge(
|
203
|
+
effective_till: (effective_till || current_time),
|
204
|
+
current_time: current_time
|
205
|
+
)
|
206
|
+
)
|
207
|
+
effective_record.update_attribute(:valid_till, current_time)
|
208
|
+
end
|
209
|
+
else
|
210
|
+
raise "no effective record found on timeline"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def ignored_update_attributes
|
215
|
+
["id", "created_at", "updated_at", "effective_from", "effective_till", "valid_from", "valid_till"]
|
216
|
+
end
|
217
|
+
|
218
|
+
def ignored_copy_attributes
|
219
|
+
["id", "created_at", "updated_at", "valid_from", "valid_till"]
|
220
|
+
end
|
221
|
+
|
222
|
+
def has_history?
|
223
|
+
effective_history.exists?
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'rails'
|
2
|
+
require "time_travel/railtie"
|
3
|
+
require "time_travel/sql_function_helper"
|
4
|
+
require "time_travel/update_helper"
|
5
|
+
require "time_travel/configuration"
|
6
|
+
|
7
|
+
module TimeTravel::TimelineHelper
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
attr_accessor :current_time
|
12
|
+
before_validation :set_current_time
|
13
|
+
before_validation :set_effective_defaults
|
14
|
+
before_create :set_validity_defaults
|
15
|
+
|
16
|
+
validates_presence_of :effective_from
|
17
|
+
validate :effective_range_timeline
|
18
|
+
validate :absence_of_valid_from_till, on: :create
|
19
|
+
|
20
|
+
scope :historically_valid, -> { where(valid_till: TimeTravel::INFINITE_DATE) }
|
21
|
+
scope :effective_now, -> { where(effective_till: TimeTravel::INFINITE_DATE, valid_till: TimeTravel::INFINITE_DATE) }
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
module ClassMethods
|
26
|
+
attr_accessor :enum_fields, :enum_items
|
27
|
+
|
28
|
+
def timeline_fields
|
29
|
+
raise "timeline_fields should be defined to return the list of fields which identify a timeline in the record"
|
30
|
+
end
|
31
|
+
|
32
|
+
def timeline(**timeline_identifiers)
|
33
|
+
Timeline.new(self,timeline_identifiers)
|
34
|
+
end
|
35
|
+
|
36
|
+
def enum_info
|
37
|
+
self.enum_items ||= self.defined_enums.symbolize_keys
|
38
|
+
self.enum_fields ||= self.enum_items.keys
|
39
|
+
[self.enum_fields, self.enum_items]
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
# set defaults
|
45
|
+
def set_current_time
|
46
|
+
self.current_time ||= Time.current
|
47
|
+
end
|
48
|
+
|
49
|
+
def set_effective_defaults
|
50
|
+
self.effective_from ||= current_time
|
51
|
+
self.effective_till ||= TimeTravel::INFINITE_DATE
|
52
|
+
end
|
53
|
+
|
54
|
+
def set_validity_defaults
|
55
|
+
self.valid_from ||= current_time
|
56
|
+
self.valid_till ||= TimeTravel::INFINITE_DATE
|
57
|
+
end
|
58
|
+
|
59
|
+
def has_history
|
60
|
+
if self.class.has_history?
|
61
|
+
self.errors.add("base", "create called on alread existing timeline")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def no_history
|
66
|
+
if not self.class.has_history?
|
67
|
+
self.errors.add("base", "update called on timeline that doesn't exist")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# validations
|
72
|
+
def effective_range_timeline
|
73
|
+
if self.effective_from > self.effective_till
|
74
|
+
self.errors.add(:base, "effective_from can't be greater than effective_till")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def absence_of_valid_from_till
|
79
|
+
if self.valid_from.present? || self.valid_till.present?
|
80
|
+
self.errors.add(:base, "valid_from and valid_till can't be set")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def validate_update(attributes)
|
85
|
+
self.assign_attributes(attributes)
|
86
|
+
self.valid?
|
87
|
+
end
|
88
|
+
|
89
|
+
def invalid_now?
|
90
|
+
!self.valid_now?
|
91
|
+
end
|
92
|
+
|
93
|
+
def valid_now?
|
94
|
+
self.valid_from.present? and self.valid_till==TimeTravel::INFINITE_DATE
|
95
|
+
end
|
96
|
+
|
97
|
+
def ineffective_now?
|
98
|
+
!self.effective_now?
|
99
|
+
end
|
100
|
+
|
101
|
+
def effective_now?
|
102
|
+
self.effective_from.present? and self.effective_till==TimeTravel::INFINITE_DATE
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module UpdateHelper
|
2
|
+
def fetch_history_for_correction(record)
|
3
|
+
correction_head = self.effective_history
|
4
|
+
.where("effective_from <= ?", record.effective_from)
|
5
|
+
.where("effective_till > ?", record.effective_from).first
|
6
|
+
correction_tail = self.effective_history
|
7
|
+
.where("effective_from < ?", record.effective_till)
|
8
|
+
.where("effective_till >= ?", record.effective_till).first
|
9
|
+
correction_range = self.effective_history
|
10
|
+
.where("effective_from > ?", record.effective_from)
|
11
|
+
.where("effective_till < ?", record.effective_till)
|
12
|
+
|
13
|
+
[correction_head, correction_range.to_a, correction_tail].flatten.compact.uniq
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_affected_timeframes(record,affected_records)
|
17
|
+
affected_timeframes = affected_records.map { |record| [record.effective_from, record.effective_till] }
|
18
|
+
affected_timeframes << [record.effective_from, record.effective_till]
|
19
|
+
affected_timeframes = affected_timeframes.flatten.uniq.sort
|
20
|
+
affected_timeframes.each_with_index.map{|time, i| {from: time, till: affected_timeframes[i+1]} }[0..-2]
|
21
|
+
end
|
22
|
+
|
23
|
+
def construct_corrected_records(new_record, affected_timeframes, affected_records, attributes)
|
24
|
+
affected_timeframes.map do |timeframe|
|
25
|
+
matched_record = affected_records.find do |record|
|
26
|
+
record.effective_from <= timeframe[:from] && record.effective_till >= timeframe[:till]
|
27
|
+
end
|
28
|
+
|
29
|
+
if matched_record
|
30
|
+
attrs = matched_record.attributes.except(*ignored_copy_attributes)
|
31
|
+
if timeframe[:from] >= new_record.effective_from && timeframe[:till] <= new_record.effective_till
|
32
|
+
attrs.merge!(attributes)
|
33
|
+
end
|
34
|
+
else
|
35
|
+
attrs = new_record.attributes.except(*ignored_copy_attributes)
|
36
|
+
end
|
37
|
+
|
38
|
+
attrs.merge!(
|
39
|
+
**@timeline_identifiers,
|
40
|
+
effective_from: timeframe[:from],
|
41
|
+
effective_till: timeframe[:till]).symbolize_keys
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def squish_record_history(corrected_records)
|
46
|
+
squished = []
|
47
|
+
|
48
|
+
corrected_records.each do |current|
|
49
|
+
# fetch and compare last vs current record
|
50
|
+
last_squished = squished.last
|
51
|
+
effective_attr = [:effective_from, :effective_till]
|
52
|
+
|
53
|
+
if last_squished &&
|
54
|
+
if last_squished.except(*effective_attr) == current.except(*effective_attr) &&
|
55
|
+
last_squished[:effective_till] == current[:effective_from]
|
56
|
+
# remove last_squished and push squished attributes
|
57
|
+
|
58
|
+
squished = squished[0..-2]
|
59
|
+
squished << last_squished.merge(effective_from: last_squished[:effective_from],
|
60
|
+
effective_till: current[:effective_till])
|
61
|
+
end
|
62
|
+
else
|
63
|
+
squished << current
|
64
|
+
end
|
65
|
+
end
|
66
|
+
squished.compact
|
67
|
+
end
|
68
|
+
|
69
|
+
def ignored_copy_attributes
|
70
|
+
["id", "created_at", "updated_at", "valid_from", "valid_till"]
|
71
|
+
end
|
72
|
+
end
|