Skip to content

Commit b98cc32

Browse files
committed
Enable bulk execution for SeedDo.seed
Add a `bulk: true` option to the `SeedDo.seed` method. Normally, `SeedDo.seed` runs queries for each model, but `SeedDo.seed(bulk: true)` combines multiple queries into one. This should give a large performance improvement when a project defines a lot of seed data. Allow the number of records in each query to be set with the `batch_size` option. The default is 1000. ```ruby SeedDo.seed(bulk: { batch_size: 100 }) ``` ## Requirements Use Rails [upsert_all](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-upsert_all) internally to implement this feature. To keep the behavior close to the existing seed-do behavior, use the `:unique_by` option, so the related constraints must have unique indexes. Also, `:unique_by` is supported only by PostgreSQL and SQLite, so this feature is not available on MySQL. ## Notes `SeedDo.seed` and `SeedDo.seed(bulk: true)` do not always produce the same results. For example, with the following seed definition: ```ruby User.seed(:name) do |u| u.name = 'first' end User.create!(name: 'second') User.seed(:name) do |u| u.name = 'third' end ``` With `SeedDo.seed`, records are created in the order `first`, `second`, `third`. However, with `SeedDo.seed(bulk: true)`, the order becomes `second`, `first`, `third`. Because of this, verify the behavior carefully before using `SeedDo.seed(bulk: true)` in an existing production environment.
1 parent 3fea44e commit b98cc32

9 files changed

Lines changed: 277 additions & 24 deletions

File tree

lib/seed-do.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,20 @@ module SeedDo
1717
# plugin, SeedDo will set it to contain `Rails.root/db/fixtures` and
1818
# `Rails.root/db/fixtures/Rails.env`
1919
mattr_accessor :fixture_paths, default: ['db/fixtures']
20-
2120
# Load seed data from files
2221
# @param [Array] fixture_paths The paths to look for seed files in
2322
# @param [Regexp] filter If given, only filenames matching this expression will be loaded
24-
def self.seed(fixture_paths = SeedDo.fixture_paths, filter = nil)
25-
Runner.new(fixture_paths, filter).run
23+
# @param [Boolean] bulk If true, bulk insert/upsert will be used
24+
def self.seed(fixture_paths = SeedDo.fixture_paths, filter = nil, bulk: false)
25+
Runner.new(fixture_paths, filter, bulk: bulk).run
26+
end
27+
28+
def self.current_runner
29+
@current_runner
30+
end
31+
32+
def self.current_runner=(runner)
33+
@current_runner = runner
2634
end
2735
end
2836

lib/seed-do/active_record_extension.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ module ActiveRecordExtension
2929
# { :x => 5, :y => 9, :name => "Office" }
3030
# )
3131
def seed(*args, &block)
32-
SeedDo::Seeder.new(self, *parse_seed_do_args(args, block)).seed
32+
runner = SeedDo.current_runner
33+
if runner&.bulk && runner.buffer
34+
constraints, data = parse_seed_do_args(args, block)
35+
runner.buffer[:seed] << { model: self, constraints: constraints, data: data }
36+
else
37+
SeedDo::Seeder.new(self, *parse_seed_do_args(args, block)).seed
38+
end
3339
end
3440

3541
# Has the same syntax as {#seed}, but if a record already exists with the same values for
@@ -41,7 +47,12 @@ def seed(*args, &block)
4147
# Person.seed_once(:id, :id => 1, :name => "Harry") # => Name *not* changed
4248
def seed_once(*args, &block)
4349
constraints, data = parse_seed_do_args(args, block)
44-
SeedDo::Seeder.new(self, constraints, data, insert_only: true).seed
50+
runner = SeedDo.current_runner
51+
if runner&.bulk && runner.buffer
52+
runner.buffer[:seed_once] << { model: self, constraints: constraints, data: data }
53+
else
54+
SeedDo::Seeder.new(self, constraints, data, insert_only: true).seed
55+
end
4556
end
4657

4758
private

lib/seed-do/runner.rb

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,51 @@
33

44
module SeedDo
55
# Runs seed files.
6-
#
7-
# It is not recommended to use this class directly. Instead, use {SeedDo.seed SeedDo.seed}, which creates
8-
# an instead of {Runner} and calls {#run #run}.
6+
# It is not recommended to use this class directly. Instead, use {SeedDo.seed SeedDo.seed}, which creates an instead of {Runner} and calls {#run #run}.
97
#
108
# @see SeedDo.seed SeedDo.seed
119
class Runner
10+
attr_accessor :buffer
11+
attr_reader :bulk
12+
1213
# @param [Array<String>] fixture_paths The paths where fixtures are located. Will use
1314
# `SeedDo.fixture_paths` if {nil}. If the argument is not an array, it will be wrapped by one.
1415
# @param [Regexp] filter If given, only seed files with a file name matching this pattern will
1516
# be used
16-
def initialize(fixture_paths = nil, filter = nil)
17+
# @param [Boolean, Hash] bulk If true, use upsert_all to insert/update records in bulk.
18+
# If a hash, can include :batch_size (default: 1000) to control the number of records
19+
# per upsert_all call.
20+
def initialize(fixture_paths = nil, filter = nil, bulk: false)
1721
@fixture_paths = Array.wrap(fixture_paths || SeedDo.fixture_paths)
1822
@filter = filter
23+
if bulk.is_a?(Hash)
24+
@bulk = true
25+
@batch_size = bulk.fetch(:batch_size, 1000)
26+
else
27+
@bulk = !bulk.nil? && bulk != false
28+
@batch_size = 1000
29+
end
30+
31+
validate_bulk_support! if @bulk
32+
end
33+
34+
def validate_bulk_support!
35+
return if ActiveRecord::Base.connection.supports_insert_conflict_target?
36+
37+
raise ArgumentError,
38+
"Bulk mode is not supported for #{ActiveRecord::Base.connection.adapter_name}. " \
39+
'The database does not support upsert operations with conflict targets. ' \
40+
'Please use SeedDo.seed without the bulk option.'
1941
end
2042

2143
# Run the seed files.
2244
def run
45+
SeedDo.current_runner = self
2346
puts "\n== Filtering seed files against regexp: #{@filter.inspect}" if @filter && !SeedDo.quiet
2447

25-
filenames.each do |filename|
26-
run_file(filename)
27-
end
48+
filenames.each { |filename| run_file(filename) }
49+
ensure
50+
SeedDo.current_runner = nil
2851
end
2952

3053
private
@@ -33,17 +56,61 @@ def run_file(filename)
3356
puts "\n== Seed from #{filename}" unless SeedDo.quiet
3457

3558
ActiveRecord::Base.transaction do
36-
open(filename) do |file|
37-
chunked_ruby = +''
38-
file.each_line do |line|
39-
if line == "# BREAK EVAL\n"
40-
eval(chunked_ruby)
41-
chunked_ruby = +''
42-
else
43-
chunked_ruby << line
44-
end
59+
if bulk
60+
with_buffer { _run_file(filename) }
61+
else
62+
_run_file(filename)
63+
end
64+
end
65+
end
66+
67+
def with_buffer
68+
self.buffer = { seed: [], seed_once: [] }
69+
yield
70+
process_buffer
71+
ensure
72+
self.buffer = nil
73+
end
74+
75+
def _run_file(filename)
76+
open(filename) do |file|
77+
chunked_ruby = +''
78+
file.each_line do |line|
79+
if line == "# BREAK EVAL\n"
80+
eval(chunked_ruby)
81+
chunked_ruby = +''
82+
else
83+
chunked_ruby << line
4584
end
46-
eval(chunked_ruby) unless chunked_ruby == ''
85+
end
86+
eval(chunked_ruby) unless chunked_ruby == ''
87+
end
88+
end
89+
90+
def process_buffer
91+
return unless buffer
92+
93+
buffer.each do |type, operations|
94+
next if operations.empty?
95+
96+
operations.chunk { |op| [op[:model], op[:constraints]] }
97+
.each do |(model, constraints), chunk|
98+
flush_chunk(model, constraints, type, chunk)
99+
end
100+
end
101+
end
102+
103+
def flush_chunk(model, constraints, type, chunk)
104+
all_data = chunk.flat_map { |op| op[:data] }
105+
106+
options = {}
107+
options[:unique_by] = constraints if model.connection.supports_insert_conflict_target?
108+
all_data.each_slice(@batch_size) do |batch|
109+
if type == :seed
110+
model.upsert_all(batch, **options)
111+
elsif type == :seed_once
112+
options[:on_duplicate] = :skip
113+
model.upsert_all(batch, **options)
47114
end
48115
end
49116
end

spec/bulk_spec.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
require 'spec_helper'
2+
3+
describe 'Bulk Insertion' do
4+
before(:all) do
5+
skip 'Bulk mode is not supported on databases without insert conflict target support' unless ActiveRecord::Base.connection.supports_insert_conflict_target?
6+
end
7+
it 'uses upsert_all when bulk option is true' do
8+
expect(BulkSeededModel).to receive(:upsert_all).with(
9+
contain_exactly(
10+
hash_including('title' => 'Bulk 1', 'login' => 'bulk1'),
11+
hash_including('title' => 'Bulk 2', 'login' => 'bulk2')
12+
),
13+
hash_including(unique_by: [:title])
14+
).and_call_original
15+
16+
SeedDo.seed("#{File.dirname(__FILE__)}/fixtures", /bulk_insert/, bulk: true)
17+
18+
expect(BulkSeededModel.count).to eq(2)
19+
item1 = BulkSeededModel.find_by(title: 'Bulk 1')
20+
expect(item1.login).to eq 'bulk1'
21+
item2 = BulkSeededModel.find_by(title: 'Bulk 2')
22+
expect(item2.login).to eq 'bulk2'
23+
end
24+
25+
it 'uses upsert_all with skip option for seed_once in bulk mode' do
26+
# Pre-create record
27+
BulkSeededModel.create!(title: 'Existing', login: 'original')
28+
29+
expect(BulkSeededModel).to receive(:upsert_all).with(
30+
contain_exactly(
31+
hash_including('title' => 'Existing', 'login' => 'new'),
32+
hash_including('title' => 'New', 'login' => 'created')
33+
),
34+
hash_including(unique_by: [:title], on_duplicate: :skip)
35+
).and_call_original
36+
37+
SeedDo.seed("#{File.dirname(__FILE__)}/fixtures", /bulk_seed_once/, bulk: true)
38+
39+
expect(BulkSeededModel.count).to eq(2)
40+
existing = BulkSeededModel.find_by(title: 'Existing')
41+
expect(existing.login).to eq 'original' # Should NOT change
42+
new_rec = BulkSeededModel.find_by(title: 'New')
43+
expect(new_rec.login).to eq 'created'
44+
end
45+
46+
it 'respects batch_size option when specified' do
47+
# With batch_size: 10, 25 records should result in 3 upsert_all calls (10, 10, 5)
48+
call_count = 0
49+
allow(BulkSeededModel).to receive(:upsert_all).and_wrap_original do |method, data, **kwargs|
50+
call_count += 1
51+
method.call(data, **kwargs)
52+
end
53+
54+
SeedDo.seed("#{File.dirname(__FILE__)}/fixtures", /bulk_large/, bulk: { batch_size: 10 })
55+
56+
expect(call_count).to eq(3)
57+
expect(BulkSeededModel.count).to eq(25)
58+
59+
# Verify all records were created
60+
(1..25).each do |i|
61+
record = BulkSeededModel.find_by(title: "Bulk #{i}")
62+
expect(record).to be_present
63+
expect(record.login).to eq "bulk#{i}"
64+
end
65+
end
66+
67+
it 'uses default batch_size of 1000 when bulk: true' do
68+
call_count = 0
69+
allow(BulkSeededModel).to receive(:upsert_all).and_wrap_original do |method, data, **kwargs|
70+
call_count += 1
71+
method.call(data, **kwargs)
72+
end
73+
74+
SeedDo.seed("#{File.dirname(__FILE__)}/fixtures", /bulk_insert/, bulk: true)
75+
76+
# With default batch_size of 1000, 2 records should result in 1 upsert_all call
77+
expect(call_count).to eq(1)
78+
expect(BulkSeededModel.count).to eq(2)
79+
end
80+
81+
it 'splits large dataset across multiple batches with custom batch_size' do
82+
call_count = 0
83+
batch_sizes = []
84+
allow(BulkSeededModel).to receive(:upsert_all).and_wrap_original do |method, data, **options|
85+
call_count += 1
86+
batch_sizes << data.size
87+
method.call(data, **options)
88+
end
89+
90+
SeedDo.seed("#{File.dirname(__FILE__)}/fixtures", /bulk_large/, bulk: { batch_size: 7 })
91+
92+
# With batch_size: 7, 25 records should result in 4 calls: [7, 7, 7, 4]
93+
expect(call_count).to eq(4)
94+
expect(batch_sizes).to eq([7, 7, 7, 4])
95+
expect(BulkSeededModel.count).to eq(25)
96+
end
97+
end

spec/fixtures/bulk_insert.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
BulkSeededModel.seed(:title) do |s|
2+
s.title = 'Bulk 1'
3+
s.login = 'bulk1'
4+
end
5+
6+
BulkSeededModel.seed(:title) do |s|
7+
s.title = 'Bulk 2'
8+
s.login = 'bulk2'
9+
end

spec/fixtures/bulk_large.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Create 25 records for batch_size testing
2+
(1..25).each do |i|
3+
BulkSeededModel.seed(:title) do |s|
4+
s.title = "Bulk #{i}"
5+
s.login = "bulk#{i}"
6+
end
7+
end

spec/fixtures/bulk_seed_once.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
BulkSeededModel.seed_once(:title) do |s|
2+
s.title = 'Existing'
3+
s.login = 'new'
4+
end
5+
6+
BulkSeededModel.seed_once(:title) do |s|
7+
s.title = 'New'
8+
s.login = 'created'
9+
end

spec/runner_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,34 @@
2121
SeedDo.seed
2222
expect(SeededModel.count).to eq 3
2323
end
24+
25+
describe 'bulk mode validation' do
26+
context 'when database does not support insert conflict target' do
27+
it 'raises ArgumentError when bulk option is true' do
28+
skip 'Test only runs on databases without insert conflict target support' if ActiveRecord::Base.connection.supports_insert_conflict_target?
29+
30+
expect do
31+
SeedDo::Runner.new(File.dirname(__FILE__) + '/fixtures', nil, bulk: true)
32+
end.to raise_error(ArgumentError, /Bulk mode is not supported/)
33+
end
34+
35+
it 'raises ArgumentError when bulk option is a hash' do
36+
skip 'Test only runs on databases without insert conflict target support' if ActiveRecord::Base.connection.supports_insert_conflict_target?
37+
38+
expect do
39+
SeedDo::Runner.new(File.dirname(__FILE__) + '/fixtures', nil, bulk: { batch_size: 500 })
40+
end.to raise_error(ArgumentError, /Bulk mode is not supported/)
41+
end
42+
end
43+
44+
context 'when database supports insert conflict target' do
45+
it 'does not raise error when bulk option is true' do
46+
skip 'Test only runs on databases with insert conflict target support' unless ActiveRecord::Base.connection.supports_insert_conflict_target?
47+
48+
expect do
49+
SeedDo::Runner.new(File.dirname(__FILE__) + '/fixtures', nil, bulk: true)
50+
end.not_to raise_error
51+
end
52+
end
53+
end
2454
end

spec/spec_helper.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
t.column :title, :string
2121
end
2222

23+
create_table :bulk_seeded_models, force: true do |t|
24+
t.column :login, :string
25+
t.column :first_name, :string
26+
t.column :last_name, :string
27+
t.column :title, :string
28+
t.index :title, unique: true
29+
end
30+
2331
create_table :seeded_model_no_primary_keys, id: false, force: true do |t|
2432
t.column :id, :string
2533
end
@@ -38,14 +46,21 @@ class SeededModel < ActiveRecord::Base
3846
before_save { throw(:abort) if fail_to_save }
3947
end
4048

41-
class SeededModelNoPrimaryKey < ActiveRecord::Base # rubocop:disable Style/OneClassPerFile
49+
class SeededModelNoPrimaryKey < ActiveRecord::Base
50+
end
51+
52+
class BulkSeededModel < ActiveRecord::Base
53+
end
54+
55+
class SeededModelNoPrimaryKey < ActiveRecord::Base
4256
end
4357

44-
class SeededModelNoSequence < ActiveRecord::Base # rubocop:disable Style/OneClassPerFile
58+
class SeededModelNoSequence < ActiveRecord::Base
4559
end
4660

4761
RSpec.configure do |config|
4862
config.before do
4963
SeededModel.delete_all
64+
BulkSeededModel.delete_all
5065
end
5166
end

0 commit comments

Comments
 (0)