Skip to content

Commit 8c42956

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 8c42956

17 files changed

Lines changed: 372 additions & 36 deletions

.rubocop.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ AllCops:
77
NewCops: enable
88
TargetRubyVersion: 3.2
99
Performance:
10-
Enabled: true
10+
Enabled: true
11+
Metrics/ClassLength:
12+
Enabled: false

lib/seed-do.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module SeedDo
77
autoload :Seeder, 'seed-do/seeder'
88
autoload :ActiveRecordExtension, 'seed-do/active_record_extension'
99
autoload :BlockHash, 'seed-do/block_hash'
10+
autoload :BulkBuffer, 'seed-do/bulk_buffer'
11+
autoload :IndividualRunner, 'seed-do/individual_runner'
1012
autoload :Runner, 'seed-do/runner'
1113
autoload :Writer, 'seed-do/writer'
1214

@@ -17,12 +19,20 @@ module SeedDo
1719
# plugin, SeedDo will set it to contain `Rails.root/db/fixtures` and
1820
# `Rails.root/db/fixtures/Rails.env`
1921
mattr_accessor :fixture_paths, default: ['db/fixtures']
20-
2122
# Load seed data from files
2223
# @param [Array] fixture_paths The paths to look for seed files in
2324
# @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
25+
# @param [Boolean] bulk If true, bulk insert/upsert will be used
26+
def self.seed(fixture_paths = SeedDo.fixture_paths, filter = nil, bulk: false)
27+
Runner.new(fixture_paths, filter, bulk: bulk).run
28+
end
29+
30+
def self.current_runner
31+
@current_runner || IndividualRunner.new
32+
end
33+
34+
def self.current_runner=(runner)
35+
@current_runner = runner
2636
end
2737
end
2838

lib/seed-do/active_record_extension.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ 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+
SeedDo.current_runner.seed(self, *parse_seed_do_args(args, block))
3333
end
3434

3535
# Has the same syntax as {#seed}, but if a record already exists with the same values for
@@ -40,8 +40,7 @@ def seed(*args, &block)
4040
# Person.seed(:id, :id => 1, :name => "Bob") # => Name changed
4141
# Person.seed_once(:id, :id => 1, :name => "Harry") # => Name *not* changed
4242
def seed_once(*args, &block)
43-
constraints, data = parse_seed_do_args(args, block)
44-
SeedDo::Seeder.new(self, constraints, data, insert_only: true).seed
43+
SeedDo.current_runner.seed_once(self, *parse_seed_do_args(args, block))
4544
end
4645

4746
private

lib/seed-do/bulk_buffer.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
module SeedDo
2+
class BulkBuffer
3+
attr_reader :buffer
4+
5+
def initialize(batch_size: 1000)
6+
@batch_size = batch_size
7+
@buffer = nil
8+
9+
validate_support!
10+
end
11+
12+
def seed(model, constraints, data)
13+
buffer[:seed] << { model: model, constraints: constraints, data: data }
14+
end
15+
16+
def seed_once(model, constraints, data)
17+
buffer[:seed_once] << { model: model, constraints: constraints, data: data }
18+
end
19+
20+
def with_buffer
21+
@buffer = { seed: [], seed_once: [] }
22+
yield
23+
process_buffer
24+
ensure
25+
@buffer = nil
26+
end
27+
28+
private
29+
30+
def validate_support!
31+
return if ActiveRecord::Base.connection.supports_insert_conflict_target?
32+
33+
raise ArgumentError,
34+
"Bulk mode is not supported for #{ActiveRecord::Base.connection.adapter_name}. " \
35+
'The database does not support upsert operations with conflict targets. ' \
36+
'Please use SeedDo.seed without the bulk option.'
37+
end
38+
39+
def process_buffer
40+
return unless buffer
41+
42+
buffer.each do |type, operations|
43+
next if operations.empty?
44+
45+
operations.chunk { |op| [op[:model], op[:constraints]] }
46+
.each do |(model, constraints), chunk|
47+
flush_chunk(model, constraints, type, chunk)
48+
end
49+
end
50+
end
51+
52+
def flush_chunk(model, constraints, type, chunk)
53+
all_data = chunk.flat_map { |op| op[:data] }
54+
55+
options = {}
56+
options[:unique_by] = constraints if model.connection.supports_insert_conflict_target?
57+
options[:on_duplicate] = :skip if type == :seed_once
58+
59+
all_data.each_slice(@batch_size) do |batch|
60+
model.upsert_all(batch, **options)
61+
end
62+
end
63+
end
64+
end

lib/seed-do/individual_runner.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module SeedDo
2+
class IndividualRunner
3+
def bulk?
4+
false
5+
end
6+
7+
def seed(model, constraints, data)
8+
SeedDo::Seeder.new(model, constraints, data).seed
9+
end
10+
11+
def seed_once(model, constraints, data)
12+
SeedDo::Seeder.new(model, constraints, data, insert_only: true).seed
13+
end
14+
15+
def buffer
16+
nil
17+
end
18+
end
19+
end

lib/seed-do/runner.rb

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,91 @@
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_reader :bulk
11+
1212
# @param [Array<String>] fixture_paths The paths where fixtures are located. Will use
1313
# `SeedDo.fixture_paths` if {nil}. If the argument is not an array, it will be wrapped by one.
1414
# @param [Regexp] filter If given, only seed files with a file name matching this pattern will
1515
# be used
16-
def initialize(fixture_paths = nil, filter = nil)
16+
# @param [Boolean, Hash] bulk If true, use upsert_all to insert/update records in bulk.
17+
# If a hash, can include :batch_size (default: 1000) to control the number of records
18+
# per upsert_all call.
19+
def initialize(fixture_paths = nil, filter = nil, bulk: false)
1720
@fixture_paths = Array.wrap(fixture_paths || SeedDo.fixture_paths)
1821
@filter = filter
22+
if bulk.is_a?(Hash)
23+
@bulk = true
24+
@bulk_buffer = SeedDo::BulkBuffer.new(batch_size: bulk.fetch(:batch_size, 1000))
25+
else
26+
@bulk = !bulk.nil? && bulk != false
27+
@bulk_buffer = SeedDo::BulkBuffer.new if @bulk
28+
end
1929
end
2030

2131
# Run the seed files.
2232
def run
33+
SeedDo.current_runner = self
2334
puts "\n== Filtering seed files against regexp: #{@filter.inspect}" if @filter && !SeedDo.quiet
2435

25-
filenames.each do |filename|
26-
run_file(filename)
36+
filenames.each { |filename| run_file(filename) }
37+
ensure
38+
SeedDo.current_runner = nil
39+
end
40+
41+
def bulk?
42+
bulk
43+
end
44+
45+
def seed(model, constraints, data)
46+
if bulk?
47+
@bulk_buffer.seed(model, constraints, data)
48+
else
49+
SeedDo::Seeder.new(model, constraints, data).seed
2750
end
2851
end
2952

53+
def seed_once(model, constraints, data)
54+
if bulk?
55+
@bulk_buffer.seed_once(model, constraints, data)
56+
else
57+
SeedDo::Seeder.new(model, constraints, data, insert_only: true).seed
58+
end
59+
end
60+
61+
def buffer
62+
@bulk_buffer&.buffer
63+
end
64+
3065
private
3166

3267
def run_file(filename)
3368
puts "\n== Seed from #{filename}" unless SeedDo.quiet
3469

3570
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
71+
if bulk
72+
@bulk_buffer.with_buffer { _run_file(filename) }
73+
else
74+
_run_file(filename)
75+
end
76+
end
77+
end
78+
79+
def _run_file(filename)
80+
open(filename) do |file|
81+
chunked_ruby = +''
82+
file.each_line do |line|
83+
if line == "# BREAK EVAL\n"
84+
eval(chunked_ruby)
85+
chunked_ruby = +''
86+
else
87+
chunked_ruby << line
4588
end
46-
eval(chunked_ruby) unless chunked_ruby == ''
4789
end
90+
eval(chunked_ruby) unless chunked_ruby == ''
4891
end
4992
end
5093

spec/bulk_buffer_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
require 'spec_helper'
2+
3+
describe SeedDo::BulkBuffer 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+
8+
it 'clears the buffer after processing' do
9+
bulk_buffer = described_class.new(batch_size: 2)
10+
11+
bulk_buffer.with_buffer do
12+
bulk_buffer.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
13+
expect(bulk_buffer.buffer).not_to be_nil
14+
end
15+
16+
expect(bulk_buffer.buffer).to be_nil
17+
end
18+
19+
it 'separates flushes by operation type' do
20+
bulk_buffer = described_class.new(batch_size: 10)
21+
22+
expect(BulkSeededModel).to receive(:upsert_all).twice.and_call_original
23+
24+
bulk_buffer.with_buffer do
25+
bulk_buffer.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
26+
bulk_buffer.seed(BulkSeededModel, [:title], [{ title: 'Bulk 2', login: 'bulk2' }])
27+
bulk_buffer.seed_once(BulkSeededModel, [:title], [{ title: 'Bulk 3', login: 'bulk3' }])
28+
end
29+
end
30+
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

0 commit comments

Comments
 (0)