time-travel 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/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
|