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,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$;