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.
@@ -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