time-travel 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ namespace :time_travel do
2
+ desc "creates sql function used by time_travel gem to manage history"
3
+ task :create_postgres_function, [:schema] => [:environment] do |task, args|
4
+ TimeTravel::SqlFunctionHelper.create(args[:schema])
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module TimeTravel
2
+ class Configuration
3
+ attr_accessor :update_mode
4
+
5
+ def initialize
6
+ @update_mode="native"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module TimeTravel
2
+ class Railtie < ::Rails::Railtie
3
+ rake_tasks do
4
+ load 'tasks/create_postgres_function.rake'
5
+ end
6
+ end
7
+ end
@@ -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