upsert 0.1.2 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +1 -0
- data/README.md +14 -10
- data/Rakefile +1 -1
- data/lib/upsert.rb +95 -10
- data/lib/upsert/active_record_upsert.rb +12 -0
- data/lib/upsert/mysql2_client.rb +160 -0
- data/lib/upsert/pg_connection.rb +84 -0
- data/lib/upsert/pg_connection/column_definition.rb +60 -0
- data/lib/upsert/row.rb +8 -8
- data/lib/upsert/sqlite3_database.rb +39 -0
- data/lib/upsert/version.rb +1 -1
- data/test/misc/get_postgres_reserved_words.rb +12 -0
- data/test/misc/mysql_reserved.txt +226 -0
- data/test/misc/pg_reserved.txt +742 -0
- data/test/shared/multibyte.rb +2 -2
- data/test/shared/reserved_words.rb +41 -0
- data/test/test_active_record_upsert.rb +23 -0
- data/test/test_mysql2.rb +2 -0
- data/test/test_pg.rb +2 -0
- data/test/test_sqlite.rb +14 -11
- metadata +17 -8
- data/lib/upsert/buffer.rb +0 -58
- data/lib/upsert/buffer/mysql2_client.rb +0 -164
- data/lib/upsert/buffer/pg_connection.rb +0 -87
- data/lib/upsert/buffer/pg_connection/column_definition.rb +0 -60
- data/lib/upsert/buffer/sqlite3_database.rb +0 -43
- data/lib/upsert/quoter.rb +0 -43
data/test/shared/multibyte.rb
CHANGED
@@ -23,8 +23,8 @@ shared_examples_for "supports multibyte" do
|
|
23
23
|
end
|
24
24
|
it "won't overflow" do
|
25
25
|
upsert = Upsert.new connection, :pets
|
26
|
-
if upsert.
|
27
|
-
max = upsert.
|
26
|
+
if upsert.respond_to?(:max_sql_bytesize)
|
27
|
+
max = upsert.send(:max_sql_bytesize)
|
28
28
|
ticks = max / 3 - 2
|
29
29
|
lambda do
|
30
30
|
loop do
|
@@ -0,0 +1,41 @@
|
|
1
|
+
shared_examples_for "doesn't blow up on reserved words" do
|
2
|
+
# collect and uniq reserved words
|
3
|
+
reserved_words = ['mysql_reserved.txt', 'pg_reserved.txt'].map do |basename|
|
4
|
+
File.expand_path("../../misc/#{basename}", __FILE__)
|
5
|
+
end.map do |path|
|
6
|
+
IO.readlines(path)
|
7
|
+
end.flatten.map(&:chomp).select(&:present?).uniq
|
8
|
+
|
9
|
+
# make lots of AR models, each of which has 10 columns named after these words
|
10
|
+
nasties = []
|
11
|
+
reserved_words.each_slice(10) do |words|
|
12
|
+
eval %{
|
13
|
+
class Nasty#{nasties.length} < ActiveRecord::Base
|
14
|
+
end
|
15
|
+
}
|
16
|
+
nasty = Object.const_get("Nasty#{nasties.length}")
|
17
|
+
nasty.class_eval do
|
18
|
+
self.primary_key = 'fake_primary_key'
|
19
|
+
col :fake_primary_key
|
20
|
+
words.each do |word|
|
21
|
+
col word
|
22
|
+
end
|
23
|
+
end
|
24
|
+
nasties << [ nasty, words ]
|
25
|
+
end
|
26
|
+
nasties.each do |nasty, _|
|
27
|
+
nasty.auto_upgrade!
|
28
|
+
end
|
29
|
+
|
30
|
+
nasties.each do |nasty, words|
|
31
|
+
it "doesn't die on reserved words #{words.join(',')}" do
|
32
|
+
upsert = Upsert.new connection, nasty.table_name
|
33
|
+
random = rand(1e3).to_s
|
34
|
+
selector = { :fake_primary_key => random, words.first => words.first }
|
35
|
+
document = words[1..-1].inject({}) { |memo, word| memo[word] = word; memo }
|
36
|
+
assert_creates nasty, [selector.merge(document)] do
|
37
|
+
upsert.row selector, document
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'mysql2'
|
3
|
+
|
4
|
+
system %{ mysql -u root -ppassword -e "DROP DATABASE IF EXISTS test_upsert; CREATE DATABASE test_upsert CHARSET utf8" }
|
5
|
+
ActiveRecord::Base.establish_connection :adapter => 'mysql2', :username => 'root', :password => 'password', :database => 'test_upsert'
|
6
|
+
|
7
|
+
require 'upsert/active_record_upsert'
|
8
|
+
|
9
|
+
describe Upsert::ActiveRecordUpsert do
|
10
|
+
before do
|
11
|
+
ActiveRecord::Base.connection.drop_table(Pet.table_name) rescue nil
|
12
|
+
Pet.auto_upgrade!
|
13
|
+
end
|
14
|
+
|
15
|
+
describe :upsert do
|
16
|
+
it "is easy to use" do
|
17
|
+
assert_creates(Pet,[{:name => 'Jerry', :good => true}]) do
|
18
|
+
Pet.upsert({:name => 'Jerry'}, :good => false)
|
19
|
+
Pet.upsert({:name => 'Jerry'}, :good => true)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/test/test_mysql2.rb
CHANGED
data/test/test_pg.rb
CHANGED
data/test/test_sqlite.rb
CHANGED
@@ -11,19 +11,20 @@ describe "upserting on sqlite" do
|
|
11
11
|
@opened_connections = []
|
12
12
|
ActiveRecord::Base.connection.drop_table(Pet.table_name) rescue nil
|
13
13
|
Pet.auto_upgrade!
|
14
|
-
@connection = new_connection
|
15
|
-
end
|
16
|
-
after do
|
17
|
-
@opened_connections.each { |c| c.close }
|
18
|
-
end
|
19
|
-
|
20
|
-
def new_connection
|
21
|
-
c = SQLite3::Database.open(File.expand_path('../../tmp/test.sqlite3', __FILE__))
|
22
|
-
@opened_connections << c
|
23
|
-
c
|
14
|
+
# @connection = new_connection
|
24
15
|
end
|
16
|
+
# after do
|
17
|
+
# @opened_connections.each { |c| c.close }
|
18
|
+
# end
|
19
|
+
|
20
|
+
# def new_connection
|
21
|
+
# c = SQLite3::Database.open(File.expand_path('../../tmp/test.sqlite3', __FILE__))
|
22
|
+
# @opened_connections << c
|
23
|
+
# c
|
24
|
+
# end
|
25
25
|
def connection
|
26
|
-
@connection
|
26
|
+
# @connection
|
27
|
+
ActiveRecord::Base.connection
|
27
28
|
end
|
28
29
|
|
29
30
|
it_also 'is a database with an upsert trick'
|
@@ -39,4 +40,6 @@ describe "upserting on sqlite" do
|
|
39
40
|
it_also "doesn't mess with timezones"
|
40
41
|
|
41
42
|
it_also 'supports binary upserts'
|
43
|
+
|
44
|
+
# it_also "doesn't blow up on reserved words"
|
42
45
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: upsert
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-06-
|
12
|
+
date: 2012-06-21 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: sqlite3
|
@@ -187,24 +187,28 @@ files:
|
|
187
187
|
- README.md
|
188
188
|
- Rakefile
|
189
189
|
- lib/upsert.rb
|
190
|
+
- lib/upsert/active_record_upsert.rb
|
190
191
|
- lib/upsert/binary.rb
|
191
|
-
- lib/upsert/
|
192
|
-
- lib/upsert/
|
193
|
-
- lib/upsert/
|
194
|
-
- lib/upsert/buffer/pg_connection/column_definition.rb
|
195
|
-
- lib/upsert/buffer/sqlite3_database.rb
|
196
|
-
- lib/upsert/quoter.rb
|
192
|
+
- lib/upsert/mysql2_client.rb
|
193
|
+
- lib/upsert/pg_connection.rb
|
194
|
+
- lib/upsert/pg_connection/column_definition.rb
|
197
195
|
- lib/upsert/row.rb
|
196
|
+
- lib/upsert/sqlite3_database.rb
|
198
197
|
- lib/upsert/version.rb
|
199
198
|
- test/helper.rb
|
199
|
+
- test/misc/get_postgres_reserved_words.rb
|
200
|
+
- test/misc/mysql_reserved.txt
|
201
|
+
- test/misc/pg_reserved.txt
|
200
202
|
- test/shared/binary.rb
|
201
203
|
- test/shared/correctness.rb
|
202
204
|
- test/shared/database.rb
|
203
205
|
- test/shared/multibyte.rb
|
206
|
+
- test/shared/reserved_words.rb
|
204
207
|
- test/shared/speed.rb
|
205
208
|
- test/shared/threaded.rb
|
206
209
|
- test/shared/timezones.rb
|
207
210
|
- test/test_active_record_connection_adapter.rb
|
211
|
+
- test/test_active_record_upsert.rb
|
208
212
|
- test/test_mysql2.rb
|
209
213
|
- test/test_pg.rb
|
210
214
|
- test/test_sqlite.rb
|
@@ -236,14 +240,19 @@ summary: Upsert for MySQL, PostgreSQL, and SQLite. Finally, all those SQL MERGE
|
|
236
240
|
codified.
|
237
241
|
test_files:
|
238
242
|
- test/helper.rb
|
243
|
+
- test/misc/get_postgres_reserved_words.rb
|
244
|
+
- test/misc/mysql_reserved.txt
|
245
|
+
- test/misc/pg_reserved.txt
|
239
246
|
- test/shared/binary.rb
|
240
247
|
- test/shared/correctness.rb
|
241
248
|
- test/shared/database.rb
|
242
249
|
- test/shared/multibyte.rb
|
250
|
+
- test/shared/reserved_words.rb
|
243
251
|
- test/shared/speed.rb
|
244
252
|
- test/shared/threaded.rb
|
245
253
|
- test/shared/timezones.rb
|
246
254
|
- test/test_active_record_connection_adapter.rb
|
255
|
+
- test/test_active_record_upsert.rb
|
247
256
|
- test/test_mysql2.rb
|
248
257
|
- test/test_pg.rb
|
249
258
|
- test/test_sqlite.rb
|
data/lib/upsert/buffer.rb
DELETED
@@ -1,58 +0,0 @@
|
|
1
|
-
class Upsert
|
2
|
-
# @private
|
3
|
-
class Buffer
|
4
|
-
class << self
|
5
|
-
def for(connection, table_name)
|
6
|
-
if connection.respond_to?(:raw_connection)
|
7
|
-
# deal with ActiveRecord::Base.connection or ActiveRecord::Base.connection_pool.checkout
|
8
|
-
connection = connection.raw_connection
|
9
|
-
end
|
10
|
-
const_get(connection.class.name.gsub(/\W+/, '_')).new connection, table_name
|
11
|
-
end
|
12
|
-
end
|
13
|
-
|
14
|
-
SINGLE_QUOTE = %{'}
|
15
|
-
DOUBLE_QUOTE = %{"}
|
16
|
-
BACKTICK = %{`}
|
17
|
-
E_AND_SINGLE_QUOTE = %{E'}
|
18
|
-
X_AND_SINGLE_QUOTE = %{x'}
|
19
|
-
USEC_SPRINTF = '%06d'
|
20
|
-
ISO8601_DATETIME = '%Y-%m-%d %H:%M:%S'
|
21
|
-
|
22
|
-
attr_reader :connection
|
23
|
-
attr_reader :table_name
|
24
|
-
attr_reader :rows
|
25
|
-
|
26
|
-
def initialize(connection, table_name)
|
27
|
-
@connection = connection
|
28
|
-
@table_name = table_name
|
29
|
-
@rows = []
|
30
|
-
end
|
31
|
-
|
32
|
-
def async?
|
33
|
-
!!@async
|
34
|
-
end
|
35
|
-
|
36
|
-
def async!
|
37
|
-
@async = true
|
38
|
-
end
|
39
|
-
|
40
|
-
def sync!
|
41
|
-
@async = false
|
42
|
-
clear
|
43
|
-
end
|
44
|
-
|
45
|
-
def add(selector, document)
|
46
|
-
rows << Row.new(self, selector, document)
|
47
|
-
if sql = chunk
|
48
|
-
execute sql
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
def clear
|
53
|
-
while sql = chunk
|
54
|
-
execute sql
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|
@@ -1,164 +0,0 @@
|
|
1
|
-
class Upsert
|
2
|
-
class Buffer
|
3
|
-
# @private
|
4
|
-
class Mysql2_Client < Buffer
|
5
|
-
include Quoter
|
6
|
-
|
7
|
-
def chunk
|
8
|
-
return if rows.empty?
|
9
|
-
all = rows.length
|
10
|
-
take = all
|
11
|
-
while take > 1 and probably_oversize?(take)
|
12
|
-
take -= 1
|
13
|
-
end
|
14
|
-
if async? and take == all
|
15
|
-
return
|
16
|
-
end
|
17
|
-
while take > 1 and oversize?(take)
|
18
|
-
$stderr.puts " Length prediction via sampling failed, shrinking" if ENV['UPSERT_DEBUG'] == 'true'
|
19
|
-
take -= 1
|
20
|
-
end
|
21
|
-
chunk = sql take
|
22
|
-
while take > 1 and chunk.bytesize > max_sql_bytesize
|
23
|
-
$stderr.puts " Supposedly exact bytesize guess failed, shrinking" if ENV['UPSERT_DEBUG'] == 'true'
|
24
|
-
take -= 1
|
25
|
-
chunk = sql take
|
26
|
-
end
|
27
|
-
if chunk.bytesize > max_sql_bytesize
|
28
|
-
raise TooBig
|
29
|
-
end
|
30
|
-
$stderr.puts " Chunk (#{take}/#{chunk.bytesize}) was #{(chunk.bytesize / max_sql_bytesize.to_f * 100).round}% of the max" if ENV['UPSERT_DEBUG'] == 'true'
|
31
|
-
@rows = rows.drop(take)
|
32
|
-
chunk
|
33
|
-
end
|
34
|
-
|
35
|
-
def execute(sql)
|
36
|
-
connection.query sql
|
37
|
-
end
|
38
|
-
|
39
|
-
def probably_oversize?(take)
|
40
|
-
estimate_sql_bytesize(take) > max_sql_bytesize
|
41
|
-
end
|
42
|
-
|
43
|
-
def oversize?(take)
|
44
|
-
sql_bytesize(take) > max_sql_bytesize
|
45
|
-
end
|
46
|
-
|
47
|
-
def columns
|
48
|
-
@columns ||= rows.first.columns
|
49
|
-
end
|
50
|
-
|
51
|
-
def insert_part
|
52
|
-
@insert_part ||= %{INSERT INTO "#{table_name}" (#{quote_idents(columns)}) VALUES }
|
53
|
-
end
|
54
|
-
|
55
|
-
def update_part
|
56
|
-
@update_part ||= begin
|
57
|
-
updaters = columns.map do |k|
|
58
|
-
qk = quote_ident k
|
59
|
-
[ qk, "VALUES(#{qk})" ].join('=')
|
60
|
-
end.join(',')
|
61
|
-
%{ ON DUPLICATE KEY UPDATE #{updaters}}
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
# where 2 is the parens
|
66
|
-
def static_sql_bytesize
|
67
|
-
@static_sql_bytesize ||= insert_part.bytesize + update_part.bytesize + 2
|
68
|
-
end
|
69
|
-
|
70
|
-
# where 3 is parens and comma
|
71
|
-
def variable_sql_bytesize(take)
|
72
|
-
rows.first(take).inject(0) { |sum, row| sum + row.values_sql_bytesize + 3 }
|
73
|
-
end
|
74
|
-
|
75
|
-
def estimate_variable_sql_bytesize(take)
|
76
|
-
p = (take / 10.0).ceil
|
77
|
-
10.0 * rows.sample(p).inject(0) { |sum, row| sum + row.values_sql_bytesize + 3 }
|
78
|
-
end
|
79
|
-
|
80
|
-
def sql_bytesize(take)
|
81
|
-
static_sql_bytesize + variable_sql_bytesize(take)
|
82
|
-
end
|
83
|
-
|
84
|
-
def estimate_sql_bytesize(take)
|
85
|
-
static_sql_bytesize + estimate_variable_sql_bytesize(take)
|
86
|
-
end
|
87
|
-
|
88
|
-
def sql(take)
|
89
|
-
all_value_sql = rows.first(take).map { |row| row.values_sql }
|
90
|
-
[ insert_part, '(', all_value_sql.join('),('), ')', update_part ].join
|
91
|
-
end
|
92
|
-
|
93
|
-
# since setting an option like :as => :hash actually persists that option to the client, don't pass any options
|
94
|
-
def max_sql_bytesize
|
95
|
-
@max_sql_bytesize ||= begin
|
96
|
-
case (row = connection.query("SHOW VARIABLES LIKE 'max_allowed_packet'").first)
|
97
|
-
when Array
|
98
|
-
row[1]
|
99
|
-
when Hash
|
100
|
-
row['Value']
|
101
|
-
else
|
102
|
-
raise "Don't know what to do if connection.query returns a #{row.class}"
|
103
|
-
end.to_i
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
def quoted_value_bytesize(v)
|
108
|
-
case v
|
109
|
-
when NilClass
|
110
|
-
4
|
111
|
-
when TrueClass
|
112
|
-
4
|
113
|
-
when FalseClass
|
114
|
-
5
|
115
|
-
when BigDecimal
|
116
|
-
v.to_s('F').bytesize
|
117
|
-
when Upsert::Binary
|
118
|
-
v.bytesize * 2 + 3
|
119
|
-
when Numeric
|
120
|
-
v.to_s.bytesize
|
121
|
-
when String
|
122
|
-
v.bytesize + 2
|
123
|
-
when Symbol
|
124
|
-
v.to_s.bytesize + 2
|
125
|
-
when Time, DateTime
|
126
|
-
24 + 2
|
127
|
-
when Date
|
128
|
-
10 + 2
|
129
|
-
else
|
130
|
-
raise "not sure how to get quoted length of #{v.class}: #{v.inspect}"
|
131
|
-
end
|
132
|
-
end
|
133
|
-
|
134
|
-
def quote_boolean(v)
|
135
|
-
v ? 'TRUE' : 'FALSE'
|
136
|
-
end
|
137
|
-
|
138
|
-
def quote_string(v)
|
139
|
-
SINGLE_QUOTE + connection.escape(v) + SINGLE_QUOTE
|
140
|
-
end
|
141
|
-
|
142
|
-
# This doubles the size of the representation.
|
143
|
-
def quote_binary(v)
|
144
|
-
X_AND_SINGLE_QUOTE + v.unpack("H*")[0] + SINGLE_QUOTE
|
145
|
-
end
|
146
|
-
|
147
|
-
# put raw binary straight into sql
|
148
|
-
# might work if we could get the encoding issues fixed when joining together the values for the sql
|
149
|
-
# alias_method :quote_binary, :quote_string
|
150
|
-
|
151
|
-
def quote_time(v)
|
152
|
-
quote_string v.strftime(ISO8601_DATETIME)
|
153
|
-
end
|
154
|
-
|
155
|
-
def quote_ident(k)
|
156
|
-
BACKTICK + connection.escape(k.to_s) + BACKTICK
|
157
|
-
end
|
158
|
-
|
159
|
-
def quote_big_decimal(v)
|
160
|
-
v.to_s('F')
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
end
|
@@ -1,87 +0,0 @@
|
|
1
|
-
require 'upsert/buffer/pg_connection/column_definition'
|
2
|
-
|
3
|
-
class Upsert
|
4
|
-
class Buffer
|
5
|
-
# @private
|
6
|
-
class PG_Connection < Buffer
|
7
|
-
include Quoter
|
8
|
-
|
9
|
-
attr_reader :merge_function
|
10
|
-
|
11
|
-
def chunk
|
12
|
-
return if rows.empty?
|
13
|
-
row = rows.shift
|
14
|
-
unless merge_function
|
15
|
-
create_merge_function row
|
16
|
-
end
|
17
|
-
hsh = row.to_hash
|
18
|
-
ordered_args = column_definitions.map do |c|
|
19
|
-
hsh[c.name]
|
20
|
-
end
|
21
|
-
%{SELECT #{merge_function}(#{quote_values(ordered_args)})}
|
22
|
-
end
|
23
|
-
|
24
|
-
def execute(sql)
|
25
|
-
connection.exec sql
|
26
|
-
end
|
27
|
-
|
28
|
-
def quote_string(v)
|
29
|
-
SINGLE_QUOTE + connection.escape_string(v) + SINGLE_QUOTE
|
30
|
-
end
|
31
|
-
|
32
|
-
def quote_binary(v)
|
33
|
-
E_AND_SINGLE_QUOTE + connection.escape_bytea(v) + SINGLE_QUOTE
|
34
|
-
end
|
35
|
-
|
36
|
-
def quote_time(v)
|
37
|
-
quote_string [v.strftime(ISO8601_DATETIME), sprintf(USEC_SPRINTF, v.usec)].join('.')
|
38
|
-
end
|
39
|
-
|
40
|
-
def quote_big_decimal(v)
|
41
|
-
v.to_s('F')
|
42
|
-
end
|
43
|
-
|
44
|
-
def quote_boolean(v)
|
45
|
-
v ? 'TRUE' : 'FALSE'
|
46
|
-
end
|
47
|
-
|
48
|
-
def quote_ident(k)
|
49
|
-
DOUBLE_QUOTE + connection.quote_ident(k.to_s) + DOUBLE_QUOTE
|
50
|
-
end
|
51
|
-
|
52
|
-
def column_definitions
|
53
|
-
@column_definitions ||= ColumnDefinition.all(connection, table_name)
|
54
|
-
end
|
55
|
-
|
56
|
-
private
|
57
|
-
|
58
|
-
def create_merge_function(example_row)
|
59
|
-
@merge_function = "pg_temp.merge_#{table_name}_#{Kernel.rand(1e11)}"
|
60
|
-
execute <<-EOS
|
61
|
-
CREATE FUNCTION #{merge_function}(#{column_definitions.map { |c| "#{c.name}_input #{c.sql_type} DEFAULT #{c.default || 'NULL'}" }.join(',') }) RETURNS VOID AS
|
62
|
-
$$
|
63
|
-
BEGIN
|
64
|
-
LOOP
|
65
|
-
-- first try to update the key
|
66
|
-
UPDATE #{table_name} SET #{column_definitions.map { |c| "#{c.name} = #{c.name}_input" }.join(',')} WHERE #{example_row.selector.keys.map { |k| "#{k} = #{k}_input" }.join(' AND ') };
|
67
|
-
IF found THEN
|
68
|
-
RETURN;
|
69
|
-
END IF;
|
70
|
-
-- not there, so try to insert the key
|
71
|
-
-- if someone else inserts the same key concurrently,
|
72
|
-
-- we could get a unique-key failure
|
73
|
-
BEGIN
|
74
|
-
INSERT INTO #{table_name}(#{column_definitions.map { |c| c.name }.join(',')}) VALUES (#{column_definitions.map { |c| "#{c.name}_input" }.join(',')});
|
75
|
-
RETURN;
|
76
|
-
EXCEPTION WHEN unique_violation THEN
|
77
|
-
-- Do nothing, and loop to try the UPDATE again.
|
78
|
-
END;
|
79
|
-
END LOOP;
|
80
|
-
END;
|
81
|
-
$$
|
82
|
-
LANGUAGE plpgsql;
|
83
|
-
EOS
|
84
|
-
end
|
85
|
-
end
|
86
|
-
end
|
87
|
-
end
|