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
data/lib/time_travel.rb
ADDED
@@ -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$;
|