Skip to content

Commit 8a3ecf2

Browse files
authored
Merge pull request #26 from willnet/upsert_all
Enable bulk upsert in SeedDo.seed
2 parents 3fea44e + e7c5aa2 commit 8a3ecf2

18 files changed

Lines changed: 439 additions & 56 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

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,30 @@ Seed files can be run automatically using `rake db:seed_do`. There are two optio
142142

143143
You can also do a similar thing in your code by calling `SeedDo.seed(fixture_paths, filter)`.
144144

145+
## Bulk upsert
146+
147+
If you load a large amount of seed data, you can enable bulk upsert mode:
148+
149+
```ruby
150+
SeedDo.seed(bulk: true)
151+
```
152+
153+
In bulk mode, SeedDo buffers seed data for each file and writes it with `upsert_all`, which can significantly reduce the number of queries.
154+
155+
You can also control the batch size per `upsert_all` call. The default is `1000`.
156+
157+
```ruby
158+
SeedDo.seed(bulk: { batch_size: 100 })
159+
```
160+
161+
### Requirements and caveats
162+
163+
- Bulk mode relies on Rails `upsert_all`
164+
- The seed constraints are passed to `unique_by`, so the related columns must have a unique index
165+
- Bulk mode is supported only on databases that support conflict targets, such as PostgreSQL and SQLite
166+
- Bulk mode is not available on MySQL
167+
- `SeedDo.seed` and `SeedDo.seed(bulk: true)` may not always produce the same record order, so verify the behavior carefully before enabling it in an existing production environment
168+
145169
## Disable output
146170

147171
To disable output from Seed Do, set `SeedDo.quiet = true`.

lib/seed-do.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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 :BulkSeeder, 'seed-do/bulk_seeder'
1011
autoload :Runner, 'seed-do/runner'
1112
autoload :Writer, 'seed-do/writer'
1213

@@ -17,12 +18,20 @@ module SeedDo
1718
# plugin, SeedDo will set it to contain `Rails.root/db/fixtures` and
1819
# `Rails.root/db/fixtures/Rails.env`
1920
mattr_accessor :fixture_paths, default: ['db/fixtures']
20-
2121
# Load seed data from files
2222
# @param [Array] fixture_paths The paths to look for seed files in
2323
# @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
24+
# @param [Boolean] bulk If true, bulk insert/upsert will be used
25+
def self.seed(fixture_paths = SeedDo.fixture_paths, filter = nil, bulk: false)
26+
Runner.new(fixture_paths, filter, bulk: bulk).run
27+
end
28+
29+
def self.current_seeder
30+
@current_seeder || Seeder.new
31+
end
32+
33+
def self.current_seeder=(seeder)
34+
@current_seeder = seeder
2635
end
2736
end
2837

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_seeder.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_seeder.seed_once(self, *parse_seed_do_args(args, block))
4544
end
4645

4746
private

lib/seed-do/bulk_seeder.rb

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

lib/seed-do/runner.rb

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,39 @@
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
1210
# @param [Array<String>] fixture_paths The paths where fixtures are located. Will use
1311
# `SeedDo.fixture_paths` if {nil}. If the argument is not an array, it will be wrapped by one.
1412
# @param [Regexp] filter If given, only seed files with a file name matching this pattern will
1513
# be used
16-
def initialize(fixture_paths = nil, filter = nil)
14+
# @param [Boolean, Hash] bulk If true, use upsert_all to insert/update records in bulk.
15+
# If a hash, can include :batch_size (default: 1000) to control the number of records
16+
# per upsert_all call.
17+
def initialize(fixture_paths = nil, filter = nil, bulk: false)
1718
@fixture_paths = Array.wrap(fixture_paths || SeedDo.fixture_paths)
1819
@filter = filter
20+
@seeder = build_seeder(bulk)
1921
end
2022

2123
# Run the seed files.
2224
def run
25+
SeedDo.current_seeder = @seeder
2326
puts "\n== Filtering seed files against regexp: #{@filter.inspect}" if @filter && !SeedDo.quiet
2427

25-
filenames.each do |filename|
26-
run_file(filename)
27-
end
28+
filenames.each { |filename| run_file(filename) }
29+
ensure
30+
SeedDo.current_seeder = nil
31+
end
32+
33+
def seed(model, constraints, data)
34+
@seeder.seed(model, constraints, data)
35+
end
36+
37+
def seed_once(model, constraints, data)
38+
@seeder.seed_once(model, constraints, data)
2839
end
2940

3041
private
@@ -33,18 +44,28 @@ def run_file(filename)
3344
puts "\n== Seed from #{filename}" unless SeedDo.quiet
3445

3546
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
47+
@seeder.with_seed_file { _run_file(filename) }
48+
end
49+
end
50+
51+
def build_seeder(bulk)
52+
return SeedDo::BulkSeeder.new(batch_size: bulk.fetch(:batch_size, 1000)) if bulk.is_a?(Hash)
53+
54+
bulk ? SeedDo::BulkSeeder.new : SeedDo::Seeder.new
55+
end
56+
57+
def _run_file(filename)
58+
open(filename) do |file|
59+
chunked_ruby = +''
60+
file.each_line do |line|
61+
if line == "# BREAK EVAL\n"
62+
eval(chunked_ruby)
63+
chunked_ruby = +''
64+
else
65+
chunked_ruby << line
4566
end
46-
eval(chunked_ruby) unless chunked_ruby == ''
4767
end
68+
eval(chunked_ruby) unless chunked_ruby == ''
4869
end
4970
end
5071

lib/seed-do/seeder.rb

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,38 @@ module SeedDo
88
#
99
# @see ActiveRecordExtension
1010
class Seeder
11-
# @param [ActiveRecord::Base] model_class The model to be seeded
12-
# @param [Array<Symbol>] constraints A list of attributes which identify a particular seed. If
13-
# a record with these attributes already exists then it will be updated rather than created.
14-
# @param [Array<Hash>] data Each item in this array is a hash containing attributes for a
15-
# particular record.
16-
# @param [Hash] options
17-
# @option options [Boolean] :quiet (SeedDo.quiet) If true, output will be silenced
18-
# @option options [Boolean] :insert_only (false) If true then existing records which match the
19-
# constraints will not be updated, even if the seed data has changed
20-
def initialize(model_class, constraints, data, options = {})
11+
def seed(model_class, constraints, data)
12+
seed_records(model_class, constraints, data)
13+
end
14+
15+
def seed_once(model_class, constraints, data)
16+
seed_records(model_class, constraints, data, insert_only: true)
17+
end
18+
19+
def with_seed_file
20+
yield
21+
end
22+
23+
private
24+
25+
# Insert/update the records as appropriate. Validation is skipped while saving.
26+
# @return [Array<ActiveRecord::Base>] The records which have been seeded
27+
def seed_records(model_class, constraints, data, options = {})
2128
@model_class = model_class
2229
@constraints = constraints.to_a.empty? ? [:id] : constraints
2330
@data = data.to_a || []
2431
@options = options.symbolize_keys
2532

26-
@options[:quiet] ||= SeedDo.quiet
27-
2833
validate_constraints!
2934
validate_data!
30-
end
3135

32-
# Insert/update the records as appropriate. Validation is skipped while saving.
33-
# @return [Array<ActiveRecord::Base>] The records which have been seeded
34-
def seed
3536
records = @model_class.transaction do
3637
@data.map { |record_data| seed_record(record_data.symbolize_keys) }
3738
end
3839
update_id_sequence
3940
records
4041
end
4142

42-
private
43-
4443
def validate_constraints!
4544
unknown_columns = @constraints.map(&:to_s) - @model_class.column_names
4645
return if unknown_columns.empty?
@@ -62,7 +61,7 @@ def seed_record(data)
6261
record = find_or_initialize_record(data)
6362
return if @options[:insert_only] && !record.new_record?
6463

65-
puts " - #{@model_class} #{data.inspect}" unless @options[:quiet]
64+
puts " - #{@model_class} #{data.inspect}" unless SeedDo.quiet
6665

6766
record.assign_attributes(data)
6867
record.save(validate: false) || raise(ActiveRecord::RecordNotSaved, 'Record not saved!')

spec/bulk_seeder_spec.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
require 'spec_helper'
2+
3+
describe SeedDo::BulkSeeder 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+
around do |example|
9+
original_quiet = SeedDo.quiet
10+
example.run
11+
SeedDo.quiet = original_quiet
12+
end
13+
14+
it 'clears the buffer after processing' do
15+
bulk_seeder = described_class.new(batch_size: 2)
16+
17+
bulk_seeder.with_seed_file do
18+
bulk_seeder.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
19+
expect(bulk_seeder.buffer).not_to be_nil
20+
end
21+
22+
expect(bulk_seeder.buffer).to be_nil
23+
end
24+
25+
it 'separates flushes by operation type' do
26+
bulk_seeder = described_class.new(batch_size: 10)
27+
28+
expect(BulkSeededModel).to receive(:upsert_all).twice.and_call_original
29+
30+
bulk_seeder.with_seed_file do
31+
bulk_seeder.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
32+
bulk_seeder.seed(BulkSeededModel, [:title], [{ title: 'Bulk 2', login: 'bulk2' }])
33+
bulk_seeder.seed_once(BulkSeededModel, [:title], [{ title: 'Bulk 3', login: 'bulk3' }])
34+
end
35+
end
36+
37+
it 'logs buffered seed operations when quiet is false' do
38+
SeedDo.quiet = false
39+
bulk_seeder = described_class.new(batch_size: 10)
40+
41+
output = capture_stdout do
42+
bulk_seeder.with_seed_file do
43+
bulk_seeder.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
44+
bulk_seeder.seed_once(BulkSeededModel, [:title], [{ title: 'Bulk 2', login: 'bulk2' }])
45+
end
46+
end
47+
48+
expect(output).to include(' - BulkSeededModel [')
49+
expect(output).to include('Bulk 1')
50+
expect(output).to include('bulk1')
51+
expect(output).to include('Bulk 2')
52+
expect(output).to include('bulk2')
53+
expect(output.lines.count).to eq(2)
54+
end
55+
56+
it 'does not log buffered seed operations when quiet is true' do
57+
SeedDo.quiet = true
58+
bulk_seeder = described_class.new(batch_size: 10)
59+
60+
expect do
61+
bulk_seeder.with_seed_file do
62+
bulk_seeder.seed(BulkSeededModel, [:title], [{ title: 'Bulk 1', login: 'bulk1' }])
63+
end
64+
end.not_to output.to_stdout
65+
end
66+
67+
def capture_stdout
68+
original_stdout = $stdout
69+
$stdout = StringIO.new
70+
yield
71+
$stdout.string
72+
ensure
73+
$stdout = original_stdout
74+
end
75+
end

0 commit comments

Comments
 (0)