Skip to content

Commit 7322020

Browse files
author
Peter Loomis
authored
Merge pull request #1 from procore/make_query_return_ids
Modify worker query to return ids of inserted records
2 parents 6471d0c + b355108 commit 7322020

File tree

7 files changed

+166
-23
lines changed

7 files changed

+166
-23
lines changed

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ gemspec
1313
# To use a debugger
1414
# gem 'byebug', group: [:development, :test]
1515

16+
group :test do
17+
gem "pg", "< 1.0"
18+
end
19+

lib/bulk_insert.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ module BulkInsert
44
extend ActiveSupport::Concern
55

66
module ClassMethods
7-
def bulk_insert(*columns, values: nil, set_size:500, ignore: false, update_duplicates: false)
7+
def bulk_insert(*columns, values: nil, set_size:500, ignore: false, update_duplicates: false, return_primary_keys: false)
88
columns = default_bulk_columns if columns.empty?
9-
worker = BulkInsert::Worker.new(connection, table_name, columns, set_size, ignore, update_duplicates)
9+
worker = BulkInsert::Worker.new(connection, table_name, primary_key, columns, set_size, ignore, update_duplicates, return_primary_keys)
1010

1111
if values.present?
1212
transaction do

lib/bulk_insert/worker.rb

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,30 @@ class Worker
55
attr_accessor :before_save_callback
66
attr_accessor :after_save_callback
77
attr_accessor :adapter_name
8-
attr_reader :ignore, :update_duplicates
8+
attr_reader :ignore, :update_duplicates, :result_sets
99

10-
def initialize(connection, table_name, column_names, set_size=500, ignore=false, update_duplicates=false)
10+
def initialize(connection, table_name, primary_key, column_names, set_size=500, ignore=false, update_duplicates=false, return_primary_keys=false)
1111
@connection = connection
1212
@set_size = set_size
1313

1414
@adapter_name = connection.adapter_name
1515
# INSERT IGNORE only fails inserts with duplicate keys or unallowed nulls not the whole set of inserts
1616
@ignore = ignore
1717
@update_duplicates = update_duplicates
18+
@return_primary_keys = return_primary_keys
1819

1920
columns = connection.columns(table_name)
2021
column_map = columns.inject({}) { |h, c| h.update(c.name => c) }
2122

23+
@primary_key = primary_key
2224
@columns = column_names.map { |name| column_map[name.to_s] }
2325
@table_name = connection.quote_table_name(table_name)
2426
@column_names = column_names.map { |name| connection.quote_column_name(name) }.join(",")
2527

2628
@before_save_callback = nil
2729
@after_save_callback = nil
2830

31+
@result_sets = []
2932
@set = []
3033
end
3134

@@ -76,14 +79,21 @@ def after_save(&block)
7679
def save!
7780
if pending?
7881
@before_save_callback.(@set) if @before_save_callback
79-
compose_insert_query.tap { |query| @connection.execute(query) if query }
82+
execute_query
8083
@after_save_callback.() if @after_save_callback
8184
@set.clear
8285
end
8386

8487
self
8588
end
8689

90+
def execute_query
91+
if query = compose_insert_query
92+
result_set = @connection.exec_query(query)
93+
@result_sets.push(result_set) if @return_primary_keys
94+
end
95+
end
96+
8797
def compose_insert_query
8898
sql = insert_sql_statement
8999
@now = Time.now
@@ -107,6 +117,7 @@ def compose_insert_query
107117
if !rows.empty?
108118
sql << rows.join(",")
109119
sql << on_conflict_statement
120+
sql << primary_key_return_statement
110121
sql
111122
else
112123
false
@@ -130,6 +141,14 @@ def insert_ignore
130141
end
131142
end
132143

144+
def primary_key_return_statement
145+
if @return_primary_keys && adapter_name =~ /\APost(?:greSQL|GIS)/i
146+
" RETURNING #{@primary_key}"
147+
else
148+
''
149+
end
150+
end
151+
133152
def on_conflict_statement
134153
if (adapter_name =~ /\APost(?:greSQL|GIS)/i && ignore )
135154
' ON CONFLICT DO NOTHING'

test/bulk_insert/worker_test.rb

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
55
@insert = BulkInsert::Worker.new(
66
Testing.connection,
77
Testing.table_name,
8+
'id',
89
%w(greeting age happy created_at updated_at color))
910
@now = Time.now
1011
end
@@ -121,6 +122,103 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
121122
assert_equal true, hello.happy?
122123
end
123124

125+
test "save! does not add to result sets when not returning primary keys" do
126+
worker = BulkInsert::Worker.new(
127+
Testing.connection,
128+
Testing.table_name,
129+
'id',
130+
%w(greeting age happy created_at updated_at color),
131+
500,
132+
false,
133+
false,
134+
false
135+
)
136+
worker.add greeting: "first"
137+
worker.add greeting: "second"
138+
worker.save!
139+
140+
assert_equal 0, worker.result_sets.count
141+
end
142+
143+
test "save! adds to result sets when returning primary keys" do
144+
worker = BulkInsert::Worker.new(
145+
Testing.connection,
146+
Testing.table_name,
147+
'id',
148+
%w(greeting age happy created_at updated_at color),
149+
500,
150+
false,
151+
false,
152+
true
153+
)
154+
worker.add greeting: "first"
155+
worker.add greeting: "second"
156+
worker.save!
157+
assert_equal 1, worker.result_sets.count
158+
assert_equal 2, worker.result_sets.map(&:to_a).flatten.count
159+
160+
worker.add greeting: "third"
161+
worker.add greeting: "fourth"
162+
worker.save!
163+
assert_equal 2, worker.result_sets.count
164+
assert_equal 4, worker.result_sets.map(&:to_a).flatten.count
165+
end
166+
167+
test "save! does not change worker result sets if there are no pending rows" do
168+
worker = BulkInsert::Worker.new(
169+
Testing.connection,
170+
Testing.table_name,
171+
'id',
172+
%w(greeting age happy created_at updated_at color),
173+
500,
174+
false,
175+
false,
176+
true
177+
)
178+
assert_no_difference -> { worker.result_sets.count } do
179+
worker.save!
180+
end
181+
end
182+
183+
test "results in the same order as the records appear in the insert statement" do
184+
worker = BulkInsert::Worker.new(
185+
Testing.connection,
186+
Testing.table_name,
187+
'id',
188+
%w(greeting age happy created_at updated_at color),
189+
500,
190+
false,
191+
false,
192+
true
193+
)
194+
195+
attributes_for_insertion = (0..20).map { |i| { age: i } }
196+
worker.add_all attributes_for_insertion
197+
results = worker.result_sets.map(&:to_a).flatten
198+
199+
returned_ids = results.map {|result| result.fetch("id").to_i }
200+
expected_age_for_id_hash = {}
201+
returned_ids.map.with_index do |id, index|
202+
expected_age_for_id_hash[id] = index
203+
end
204+
205+
new_saved_records = Testing.find(returned_ids)
206+
new_saved_records.each do |record|
207+
assert_same(expected_age_for_id_hash[record.id], record.age)
208+
end
209+
end
210+
211+
test "initialized with empty result_sets array" do
212+
new_worker = BulkInsert::Worker.new(
213+
Testing.connection,
214+
Testing.table_name,
215+
'id',
216+
%w(greeting age happy created_at updated_at color)
217+
)
218+
assert_instance_of(Array, new_worker.result_sets)
219+
assert_empty new_worker.result_sets
220+
end
221+
124222
test "save! calls the after_save handler" do
125223
x = 41
126224

@@ -214,7 +312,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
214312
end
215313

216314
test "adapter dependent default methods" do
217-
assert_equal @insert.adapter_name, 'SQLite'
315+
assert_equal @insert.adapter_name, 'PostgreSQL'
218316
assert_equal @insert.insert_sql_statement, "INSERT INTO \"testings\" (\"greeting\",\"age\",\"happy\",\"created_at\",\"updated_at\",\"color\") VALUES "
219317

220318
@insert.add ["Yo", 15, false, nil, nil]
@@ -225,6 +323,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
225323
mysql_worker = BulkInsert::Worker.new(
226324
Testing.connection,
227325
Testing.table_name,
326+
'id',
228327
%w(greeting age happy created_at updated_at color),
229328
500, # batch size
230329
true) # ignore
@@ -244,6 +343,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
244343
mysql_worker = BulkInsert::Worker.new(
245344
Testing.connection,
246345
Testing.table_name,
346+
'id',
247347
%w(greeting age happy created_at updated_at color),
248348
500, # batch size
249349
true, # ignore
@@ -262,6 +362,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
262362
mysql_worker = BulkInsert::Worker.new(
263363
Testing.connection,
264364
Testing.table_name,
365+
'id',
265366
%w(greeting age happy created_at updated_at color),
266367
500, # batch size
267368
true) # ignore
@@ -278,32 +379,41 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
278379
pgsql_worker = BulkInsert::Worker.new(
279380
Testing.connection,
280381
Testing.table_name,
382+
'id',
281383
%w(greeting age happy created_at updated_at color),
282384
500, # batch size
283-
true) # ignore
385+
true, # ignore
386+
false, # update duplicates
387+
true # return primary key
388+
)
284389
pgsql_worker.adapter_name = 'PostgreSQL'
285390
pgsql_worker.add ["Yo", 15, false, nil, nil]
286391

287-
assert_equal pgsql_worker.compose_insert_query, "INSERT INTO \"testings\" (\"greeting\",\"age\",\"happy\",\"created_at\",\"updated_at\",\"color\") VALUES ('Yo',15,'f',NULL,NULL,'chartreuse') ON CONFLICT DO NOTHING"
392+
assert_equal pgsql_worker.compose_insert_query, "INSERT INTO \"testings\" (\"greeting\",\"age\",\"happy\",\"created_at\",\"updated_at\",\"color\") VALUES ('Yo',15,'f',NULL,NULL,'chartreuse') ON CONFLICT DO NOTHING RETURNING id"
288393
end
289394

290395
test "adapter dependent PostGIS methods" do
291396
pgsql_worker = BulkInsert::Worker.new(
292397
Testing.connection,
293398
Testing.table_name,
399+
'id',
294400
%w(greeting age happy created_at updated_at color),
295401
500, # batch size
296-
true) # ignore
402+
true, # ignore
403+
false, # update duplicates
404+
true # return primary key
405+
) # ignore
297406
pgsql_worker.adapter_name = 'PostGIS'
298407
pgsql_worker.add ["Yo", 15, false, nil, nil]
299408

300-
assert_equal pgsql_worker.compose_insert_query, "INSERT INTO \"testings\" (\"greeting\",\"age\",\"happy\",\"created_at\",\"updated_at\",\"color\") VALUES ('Yo',15,'f',NULL,NULL,'chartreuse') ON CONFLICT DO NOTHING"
409+
assert_equal pgsql_worker.compose_insert_query, "INSERT INTO \"testings\" (\"greeting\",\"age\",\"happy\",\"created_at\",\"updated_at\",\"color\") VALUES ('Yo',15,'f',NULL,NULL,'chartreuse') ON CONFLICT DO NOTHING RETURNING id"
301410
end
302411

303412
test "adapter dependent sqlite3 methods (with lowercase adapter name)" do
304413
sqlite_worker = BulkInsert::Worker.new(
305414
Testing.connection,
306415
Testing.table_name,
416+
'id',
307417
%w(greeting age happy created_at updated_at color),
308418
500, # batch size
309419
true) # ignore
@@ -317,6 +427,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
317427
sqlite_worker = BulkInsert::Worker.new(
318428
Testing.connection,
319429
Testing.table_name,
430+
'id',
320431
%w(greeting age happy created_at updated_at color),
321432
500, # batch size
322433
true) # ignore
@@ -330,6 +441,7 @@ class BulkInsertWorkerTest < ActiveSupport::TestCase
330441
mysql_worker = BulkInsert::Worker.new(
331442
Testing.connection,
332443
Testing.table_name,
444+
'id',
333445
%w(greeting age happy created_at updated_at color),
334446
500, # batch size
335447
false, # ignore

test/bulk_insert_test.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,24 @@ class BulkInsertTest < ActiveSupport::TestCase
2020
end
2121
end
2222

23+
test "worker should not have any result sets without option for returning primary keys" do
24+
worker = Testing.bulk_insert
25+
worker.add greeting: "hello"
26+
worker.save!
27+
assert_empty worker.result_sets
28+
end
29+
30+
test "with option to return primary keys, worker should have result sets" do
31+
worker = Testing.bulk_insert(return_primary_keys: true)
32+
worker.add greeting: "yo"
33+
worker.save!
34+
assert_equal 1, worker.result_sets.count
35+
end
36+
2337
test "bulk_insert with array should save the array immediately" do
2438
assert_difference "Testing.count", 2 do
2539
Testing.bulk_insert values: [
26-
[ "Hello", 15, true, "green" ],
40+
[ "Hello", 15, true, Time.now, Time.now, "green" ],
2741
{ greeting: "Hey", age: 20, happy: false }
2842
]
2943
end

test/dummy/config/database.yml

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,16 @@
1-
# SQLite version 3.x
2-
# gem install sqlite3
3-
#
4-
# Ensure the SQLite 3 gem is defined in your Gemfile
5-
# gem 'sqlite3'
6-
#
71
default: &default
8-
adapter: sqlite3
2+
adapter: postgresql
93
pool: 5
104
timeout: 5000
115

126
development:
137
<<: *default
14-
database: db/development.sqlite3
8+
database: bulk_insert_development
159

1610
# Warning: The database defined as "test" will be erased and
1711
# re-generated from your development database when you run "rake".
1812
# Do not set this db to the same as development or production.
1913
test:
2014
<<: *default
21-
database: db/test.sqlite3
15+
database: bulk_insert_test
2216

23-
production:
24-
<<: *default
25-
database: db/production.sqlite3

test/dummy/db/schema.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
ActiveRecord::Schema.define(version: 20151028194232) do
1515

16+
# These are extensions that must be enabled in order to support this database
17+
enable_extension "plpgsql"
18+
1619
create_table "testings", force: :cascade do |t|
1720
t.string "greeting"
1821
t.integer "age"

0 commit comments

Comments
 (0)