time-travel 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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$;
|