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,3 @@
1
+ module TimeTravel
2
+ VERSION = '1.0.0' # Placwholder version
3
+ end
@@ -0,0 +1,22 @@
1
+ require 'rails'
2
+ require "time_travel/railtie"
3
+ require "time_travel/sql_function_helper"
4
+ require "time_travel/configuration"
5
+
6
+ module TimeTravel
7
+ INFINITE_DATE = Time.new(3000,1,1,0,0,0,"+00:00")
8
+ PRECISE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%6N"
9
+
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+ end
19
+ end
20
+
21
+ require "time_travel/timeline"
22
+ require "time_travel/timeline_helper"
@@ -0,0 +1,279 @@
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/timeline"
6
+
7
+ module TimeTravel
8
+ extend ActiveSupport::Concern
9
+ include UpdateHelper
10
+
11
+ INFINITE_DATE = Time.find_zone('UTC').local(3000,1,1)
12
+ PRECISE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%6N"
13
+
14
+ included do
15
+
16
+ attr_accessor :current_time, :call_original, :create_on_update
17
+ before_validation :set_current_time
18
+ before_validation :set_effective_defaults
19
+ before_create :set_validity_defaults
20
+
21
+ validate :absence_of_valid_from_till, on: :create, unless: :call_original
22
+ validates_presence_of :effective_from
23
+ validate :effective_range_timeline
24
+ # validate :history_present, on: :create, unless: :call_original
25
+ # validate :history_absent, on: :update, unless: :call_original
26
+ scope :historically_valid, -> { where(valid_till: INFINITE_DATE) }
27
+ scope :effective_now, -> { where(effective_till: INFINITE_DATE, valid_till: INFINITE_DATE) }
28
+ end
29
+
30
+ module ClassMethods
31
+ attr_accessor :enum_fields, :enum_items
32
+ def time_travel_identifiers
33
+ raise "Please implement time_travel_identifier method to return an array of indentifiers to fetch a single timeline"
34
+ end
35
+
36
+
37
+ def timeline_clauses(*identifiers)
38
+ clauses = {}
39
+ identifiers.flatten!
40
+ time_travel_identifiers.each_with_index do | identifier_key, index |
41
+ clauses[identifier_key] = identifiers[index]
42
+ end
43
+ clauses
44
+ end
45
+
46
+ def history(*identifiers)
47
+ p identifiers
48
+ where(valid_till: INFINITE_DATE, **timeline_clauses(identifiers)).order("effective_from ASC")
49
+ end
50
+
51
+ def as_of(effective_date, *identifiers)
52
+ effective_record = history(*identifiers)
53
+ .where("effective_from <= ?", effective_date)
54
+ .where("effective_till > ?", effective_date)
55
+ effective_record.first if effective_record.exists?
56
+ end
57
+
58
+ def update_history(attribute_set, latest_transactions: false)
59
+ current_time = Time.current
60
+ other_attrs = (self.column_names - ["id", "created_at", "updated_at", "valid_from", "valid_till"])
61
+ empty_obj_attrs = other_attrs.map{|attr| {attr => nil}}.reduce(:merge!).with_indifferent_access
62
+ query = ActiveRecord::Base.connection.quote(self.unscoped.where(valid_till: INFINITE_DATE).to_sql)
63
+ table_name = ActiveRecord::Base.connection.quote(self.table_name)
64
+
65
+ attribute_set.each_slice(batch_size).to_a.each do |batched_attribute_set|
66
+ batched_attribute_set.each do |attrs|
67
+ attrs.symbolize_keys!
68
+ set_enum(attrs)
69
+ attrs[:timeline_clauses], attrs[:update_attrs] = attrs.partition do |key, value|
70
+ key.in?(time_travel_identifiers.map(&:to_sym))
71
+ end.map(&:to_h).map(&:symbolize_keys!)
72
+ if attrs[:timeline_clauses].empty? || attrs[:timeline_clauses].values.any?(&:blank?)
73
+ raise "Timeline identifiers can't be empty"
74
+ end
75
+ obj_current_time = attrs[:update_attrs].delete(:current_time) || current_time
76
+ attrs[:effective_from] = db_timestamp(attrs[:update_attrs].delete(:effective_from) || obj_current_time)
77
+ attrs[:effective_till] = db_timestamp(attrs[:update_attrs].delete(:effective_till) || INFINITE_DATE)
78
+ attrs[:current_time] = db_timestamp(obj_current_time)
79
+ attrs[:infinite_date] = db_timestamp(INFINITE_DATE)
80
+ attrs[:empty_obj_attrs] = empty_obj_attrs.merge(attrs[:timeline_clauses])
81
+ end
82
+ attrs = ActiveRecord::Base.connection.quote(batched_attribute_set.to_json)
83
+ begin
84
+ result = ActiveRecord::Base.connection.execute("select update_bulk_history(#{query},#{table_name},#{attrs},#{latest_transactions})")
85
+ rescue => e
86
+ ActiveRecord::Base.connection.execute 'ROLLBACK'
87
+ raise e
88
+ end
89
+ end
90
+ end
91
+
92
+ def set_enum(attrs)
93
+ enum_fields, enum_items = enum_info
94
+ enum_fields.each do |key|
95
+ string_value = attrs[key]
96
+ attrs[key] = enum_items[key][string_value] unless string_value.blank?
97
+ end
98
+ end
99
+
100
+ def db_timestamp(datetime)
101
+ datetime.to_datetime.utc.strftime(PRECISE_TIME_FORMAT)
102
+ end
103
+
104
+ def batch_size
105
+ self.count
106
+ end
107
+
108
+ def enum_info
109
+ self.enum_items ||= defined_enums.symbolize_keys
110
+ self.enum_fields ||= self.enum_items.keys
111
+ [self.enum_fields, self.enum_items]
112
+ end
113
+
114
+ def has_history?(attributes)
115
+ self.exists?(**timeline_clauses(attributes))
116
+ end
117
+
118
+ def history_present
119
+ if self.has_history?
120
+ self.errors.add(:base, "already has history")
121
+ end
122
+ end
123
+
124
+ def history_absent
125
+ if not self.has_history?
126
+ self.errors.add(:base, "does not have history")
127
+ end
128
+ end
129
+
130
+ def update_timeline(attributes)
131
+ if self.has_history?(attributes)
132
+ base_update(record, attributes, raise_error: true)
133
+ self.history.where(effective_from: effective_from).first
134
+ else
135
+ self.create(attributes)
136
+ end
137
+ end
138
+
139
+ def base_update(record, attributes, raise_error: false)
140
+ if ENV["TIME_TRAVEL_POSTGRES_MODE"]
141
+ return base_update_sql(record, attributes, raise_error: raise_error)
142
+ else
143
+ return base_update_native(record, attributes, raise_error: raise_error)
144
+ end
145
+ end
146
+
147
+ def base_update_native(record, attributes, raise_error: false)
148
+ begin
149
+ return true if attributes.symbolize_keys!.empty?
150
+ attributes = { effective_from: nil, effective_till: nil }.merge(attributes)
151
+ raise(ActiveRecord::RecordInvalid.new(self)) unless record.validate_update(attributes)
152
+
153
+ affected_records = fetch_history_for_correction
154
+ affected_timeframes = get_affected_timeframes(affected_records)
155
+
156
+ corrected_records = construct_corrected_records(affected_timeframes, affected_records, attributes)
157
+ squished_records = squish_record_history(corrected_records)
158
+
159
+ self.class.transaction do
160
+ squished_records.each do |record|
161
+ self.class.create!(
162
+ record.merge(
163
+ call_original: true,
164
+ valid_from: current_time,
165
+ valid_till: INFINITE_DATE)
166
+ )
167
+ end
168
+
169
+ affected_records.each {|record| record.update_attribute(:valid_till, current_time)}
170
+ end
171
+ true
172
+ rescue => e
173
+ raise e if raise_error
174
+ p "encountered error on update - #{e.message}"
175
+ false
176
+ end
177
+ end
178
+
179
+ def base_update_sql(record, update_attributes, raise_error: false)
180
+ begin
181
+ return true if update_attributes.symbolize_keys!.empty?
182
+ update_attributes.except!(:call_original)
183
+ attributes_for_validation = { effective_from: nil, effective_till: nil }.merge(update_attributes)
184
+ raise(ActiveRecord::RecordInvalid.new(self)) unless record.validate_update(attributes_for_validation)
185
+
186
+ update_attrs = update_attributes.merge(effective_from: effective_from, effective_till: effective_till, current_time: current_time).merge(timeline_clauses)
187
+ self.class.update_history([update_attrs])
188
+ rescue => e
189
+ raise e if raise_error
190
+ p "encountered error on update - #{e.message}"
191
+ false
192
+ end
193
+ end
194
+
195
+ def terminate_timeline(attributes, effective_till, raise_error: false)
196
+ begin
197
+ current_time=Time.current
198
+ effective_record = self.history(attributes).where(effective_till: INFINITE_DATE).first
199
+ if effective_record.present?
200
+ attributes = effective_record.attributes.except(*ignored_copy_attributes)
201
+ self.transaction do
202
+ self.create!(
203
+ attributes.merge(
204
+ call_original: true,
205
+ effective_till: effective_till,
206
+ valid_from: current_time,
207
+ valid_till: INFINITE_DATE)
208
+ )
209
+ effective_record.update_attribute(:valid_till, current_time)
210
+ end
211
+ else
212
+ raise "no effective record found"
213
+ end
214
+ rescue => e
215
+ raise e if raise_error
216
+ p "encountered error on delete - #{e.message}"
217
+ false
218
+ end
219
+ end
220
+
221
+
222
+
223
+ end
224
+
225
+ # set defaults
226
+ def set_current_time
227
+ self.current_time ||= Time.current
228
+ end
229
+
230
+ def set_effective_defaults
231
+ self.effective_from ||= current_time
232
+ self.effective_till ||= INFINITE_DATE
233
+ end
234
+
235
+ def set_validity_defaults
236
+ self.valid_from ||= current_time
237
+ self.valid_till ||= INFINITE_DATE
238
+ end
239
+
240
+ # validations
241
+ def absence_of_valid_from_till
242
+ if self.valid_from.present? || self.valid_till.present?
243
+ self.errors.add(:base, "valid_from and valid_till can't be set")
244
+ end
245
+ end
246
+
247
+ def effective_range_timeline
248
+ if self.effective_from > self.effective_till
249
+ self.errors.add(:base, "effective_from can't be greater than effective_till")
250
+ end
251
+ end
252
+
253
+ def validate_update(attributes)
254
+ self.assign_attributes(attributes)
255
+ self.valid?
256
+ end
257
+
258
+ def invalid_now?
259
+ !self.valid_now?
260
+ end
261
+
262
+ def valid_now?
263
+ self.valid_from.present? and self.valid_till==INFINITE_DATE
264
+ end
265
+
266
+ def ineffective_now?
267
+ !self.effective_now?
268
+ end
269
+
270
+ def effective_now?
271
+ self.effective_from.present? and self.effective_till==INFINITE_DATE
272
+ end
273
+
274
+ def ignored_copy_attributes
275
+ ["id", "created_at", "updated_at", "valid_from", "valid_till"]
276
+ end
277
+
278
+ end
279
+
@@ -0,0 +1,67 @@
1
+ do $$
2
+ DECLARE
3
+ routine_record text;
4
+ BEGIN
5
+ IF EXISTS ( SELECT 1 FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'create_column_value' ) THEN
6
+ SELECT CONCAT(routine_schema, '.', routine_name) INTO routine_record FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'create_column_value' limit 1;
7
+ EXECUTE concat('DROP FUNCTION ', routine_record);
8
+ END IF;
9
+ END
10
+ $$;
11
+
12
+ CREATE OR REPLACE FUNCTION create_column_value(
13
+ target json,
14
+ time_current timestamp,
15
+ table_name text,
16
+ update_attributes json,
17
+ infinite_date timestamp
18
+ )
19
+ RETURNS bigint
20
+ LANGUAGE 'plpgsql'
21
+
22
+ COST 100
23
+ VOLATILE
24
+ AS $BODY$
25
+
26
+ DECLARE
27
+ _columns text;
28
+ _values text;
29
+ _key text;
30
+ _value text;
31
+ c bigint;
32
+ ignore_for_copy text [] := array['id', 'created_at', 'updated_at', 'valid_from', 'valid_till'];
33
+ update_columns text[] ;
34
+ begin
35
+ IF target ->> 'effective_from' = target ->> 'effective_till' THEN
36
+ return null;
37
+ ELSE
38
+ FOR _key, _value IN SELECT * FROM json_each(update_attributes) LOOP
39
+ update_columns := update_columns || _key ;
40
+ END LOOP;
41
+
42
+ FOR _key, _value IN SELECT * FROM json_each(target) LOOP
43
+ IF _key = ANY(ignore_for_copy || update_columns) THEN
44
+ ELSE
45
+ _columns := concat(_columns, quote_ident(_key), ',');
46
+ _values := concat(_values, _value, ',');
47
+ END IF;
48
+ END LOOP;
49
+
50
+ FOR _key, _value IN SELECT * FROM json_each(update_attributes) LOOP
51
+ IF _key = ANY(ignore_for_copy) THEN
52
+ ELSE
53
+ _columns := concat(_columns, quote_ident(_key), ',');
54
+ _values := concat(_values, _value, ',');
55
+ END IF;
56
+ END LOOP;
57
+
58
+ _columns := concat('(',_columns, 'valid_from,', 'valid_till', ')');
59
+ _values := concat('(',_values, '"', time_current, '","', infinite_date, '")');
60
+ _values := replace(_values, Chr(34), Chr(39));
61
+ -- RAISE NOTICE 'insert statement %', concat('INSERT INTO ', table_name, ' ', _columns, ' VALUES ', _values , 'RETURNING id') ;
62
+ EXECUTE concat('INSERT INTO ', table_name, ' ', _columns, ' VALUES ', _values , 'RETURNING id') INTO c;
63
+ return c;
64
+ END IF;
65
+ end
66
+
67
+ $BODY$;
@@ -0,0 +1,36 @@
1
+ do $$
2
+ DECLARE
3
+ routine_record text;
4
+ BEGIN
5
+ IF EXISTS ( SELECT 1 FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'get_json_attrs' ) THEN
6
+ SELECT CONCAT(routine_schema, '.', routine_name) INTO routine_record FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'get_json_attrs' limit 1;
7
+ EXECUTE concat('DROP FUNCTION ', routine_record);
8
+ END IF;
9
+ END
10
+ $$;
11
+
12
+ CREATE OR REPLACE FUNCTION get_json_attrs(
13
+ target jsonb,
14
+ update_attributes jsonb)
15
+ RETURNS json
16
+ LANGUAGE 'plpgsql'
17
+
18
+ COST 100
19
+ VOLATILE
20
+ AS $BODY$
21
+
22
+ DECLARE
23
+ _key text;
24
+ temp jsonb;
25
+ ignore_for_copy text [] := array['id', 'created_at', 'updated_at', 'valid_from', 'valid_till'];
26
+ begin
27
+ ignore_for_copy := ignore_for_copy || ARRAY(SELECT jsonb_object_keys(update_attributes));
28
+ temp := target;
29
+ FOREACH _key IN ARRAY ignore_for_copy LOOP
30
+ temp := temp - _key;
31
+ END LOOP;
32
+
33
+ RETURN temp || update_attributes;
34
+ end
35
+
36
+ $BODY$;
@@ -0,0 +1,68 @@
1
+ do $$
2
+ DECLARE
3
+ routine_record text;
4
+ BEGIN
5
+ IF EXISTS ( SELECT 1 FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'update_bulk_history') THEN
6
+ SELECT CONCAT(routine_schema, '.', routine_name) INTO routine_record FROM Information_Schema.Routines WHERE Routine_Type ='FUNCTION' AND routine_name = 'update_bulk_history' limit 1;
7
+ EXECUTE concat('DROP FUNCTION ', routine_record);
8
+ END IF;
9
+ END
10
+ $$;
11
+
12
+ CREATE OR REPLACE FUNCTION update_bulk_history(
13
+ query text,
14
+ table_name text,
15
+ update_attrs text,
16
+ latest_transactions boolean
17
+ )
18
+
19
+ RETURNS void
20
+ LANGUAGE 'plpgsql'
21
+
22
+ COST 100
23
+ VOLATILE
24
+ AS $BODY$
25
+
26
+ DECLARE
27
+ record_values json;
28
+ record text[];
29
+ new_query text;
30
+ new_timeline_clauses text;
31
+ new_update_arr text[];
32
+ effective_from text;
33
+ effective_till text;
34
+ time_current text;
35
+ infinite_date text;
36
+ timeline_clauses text;
37
+ empty_obj_attrs text;
38
+ _key text;
39
+ _value text;
40
+ begin
41
+
42
+ FOREACH record_values IN ARRAY(SELECT ARRAY(SELECT json_array_elements(update_attrs::json))) LOOP
43
+
44
+ update_attrs := (record_values->'update_attrs');
45
+ timeline_clauses := (record_values->'timeline_clauses');
46
+ empty_obj_attrs := (record_values->'empty_obj_attrs');
47
+
48
+ effective_from := (record_values->'effective_from');
49
+ effective_till := (record_values->'effective_till');
50
+ time_current := (record_values->'current_time');
51
+ infinite_date := (record_values->'infinite_date');
52
+
53
+ new_query := query;
54
+
55
+ -- build query with timeline clauses
56
+ FOR _key, _value IN SELECT * FROM json_each(timeline_clauses::json) LOOP
57
+ new_query := concat(new_query, ' AND "', table_name, '"."', _key, '" = ', REPLACE(_value, '"', ''''));
58
+ END LOOP;
59
+
60
+ IF latest_transactions THEN
61
+ PERFORM update_latest (new_query, table_name, update_attrs, empty_obj_attrs, effective_from::timestamp, effective_till::timestamp, time_current::timestamp, infinite_date::timestamp);
62
+ ELSE
63
+ PERFORM update_history (new_query, table_name, update_attrs, empty_obj_attrs, effective_from::timestamp, effective_till::timestamp, time_current::timestamp, infinite_date::timestamp);
64
+ END IF;
65
+ END LOOP;
66
+ end
67
+
68
+ $BODY$;