Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/activerecord-bulk_update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "arel"

require "activerecord-bulk_update/activerecord/bulk_insert"
require "activerecord-bulk_update/activerecord/bulk_save"
require "activerecord-bulk_update/activerecord/bulk_update"
require "activerecord-bulk_update/activerecord/querying"
require "activerecord-bulk_update/activerecord/relation"
Expand Down
72 changes: 72 additions & 0 deletions lib/activerecord-bulk_update/activerecord/bulk_save.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module ActiveRecord
# Builds the query to save multiple records in the least amount of statements possible.
class BulkSave
attr_reader :model, :touch, :validate, :saves, :records

def initialize(model, saves, touch:, validate:)
@model = model
@touch = touch
@saves = saves
@association_names = {}
@records = []
@validate = validate
end

def save_records
extract_records(saves)
select_records_with_changes
raise ActiveRecord::RecordInvalid if validate && validate_records

model.transaction do
group_records.each do |model, delete_records, update_records, create_records|
model.where(model.primary_key => delete_records.map(&:id_in_database)).delete_all if delete_records.any?
BulkUpdate.new(model, update_records, touch: touch).update_records if update_records.any?
BulkInsert.new(model, create_records, touch: touch, ignore_persisted: false).insert_records if create_records.any?
end
end

saves
end

private
def extract_records(saves)
Array(saves).each do |record|
raise TypeError, "expected ActiveRecord::Base, got #{record}" unless record.is_a?(ActiveRecord::Base)
raise ActiveRecordError, "cannot save a destroyed record" if record.destroyed?

next if records.include?(record) # Prevents infinite loops

records << record

association_names(record.class).each do |association_name|
extract_records(record.association(association_name).target)
end
end
end

def select_records_with_changes
records.select! { |record| record.changed? || (record.persisted? && record.marked_for_destruction?) }
end

def validate_records
records.map(&:valid?).any?(false)
end

def group_records
records
.group_by(&:class)
.map do |model, grouped|
grouped
.partition(&:new_record?)
.then { |new, persisted| persisted.partition(&:marked_for_destruction?).push(new) }
.unshift(model.all)
end
end

def association_names(model)
@association_names[model] ||= model.reflections.keys.map(&:to_sym)
end
end
end
2 changes: 1 addition & 1 deletion lib/activerecord-bulk_update/activerecord/bulk_update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def values_list
end

def extract_values_from_records
raise ActiveRecordError, "cannot bulk update a model without primary_key" unless primary_key
raise UnknownPrimaryKey, model unless primary_key
raise TypeError, "expected [] or ActiveRecord::Relation, got #{updates}" unless updates.is_a?(Array) || updates.is_a?(Relation)

@filtering_attributes = [primary_key]
Expand Down
2 changes: 2 additions & 0 deletions lib/activerecord-bulk_update/activerecord/querying.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Querying
:bulk_create,
:bulk_create!,
:bulk_insert,
:bulk_save,
:bulk_save!,
:bulk_update,
:bulk_update!,
:bulk_update_all,
Expand Down
12 changes: 12 additions & 0 deletions lib/activerecord-bulk_update/activerecord/relation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ def bulk_insert(inserts, ignore_persisted: false, touch: false)
BulkInsert.new(self, inserts, ignore_persisted: ignore_persisted, touch: touch).insert_records
end

def bulk_save(*args)
bulk_save!(*args)
rescue ActiveRecord::RecordInvalid
false
end

def bulk_save!(saves, touch: true, validate: true)
BulkSave.new(self, saves, touch: touch, validate: validate).save_records

true
end

def bulk_update(*args)
bulk_update!(*args)
rescue ActiveRecord::RecordInvalid
Expand Down
122 changes: 122 additions & 0 deletions test/activerecord/bulk_save_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# frozen_string_literal: true

require "./test/test_helper"

module ActiveRecord
describe BulkSave do
describe "#save_records" do
def save_records
BulkSave.new(@model, @saves, touch: @touch, validate: @validate).save_records
end

before do
@model = FakeRecord.all
@touch = true
@validate = true
end

describe "when given activerecord instances with unpersisted changes" do
before do
first = FakeRecord.find_by!(name: "first").tap { |record| record.name = "new" }
fake_records(:second).mark_for_destruction
fake_records(:third).phony_records.new(name: "asdf")
fake_records(:third).phony_records.new(name: "fdsa")
fourth = FakeRecord.new(name: "fourth")

@saves = [first, fake_records(:second), fake_records(:third), fourth]
end

it "updates the first record" do
assert_change(-> { fake_records(:first).reload.name }, to: "new") { save_records }
end

it "deletes the second record" do
assert_change(-> { FakeRecord.exists?(name: "second") }, from: true, to: false) { save_records }
end

it "creates the new associations for the third record" do
assert_change(-> { fake_records(:third).phony_records.count }, from: 0, to: 2) { save_records }
end

it "creates the fourth record" do
assert_change(-> { FakeRecord.exists?(name: "fourth") }, from: false, to: true) { save_records }
end

it "touches the updated_at" do
assert_change(-> { fake_records(:first).reload.updated_at }) { save_records }
end

it "returns the Array of records" do
assert_equal(@saves, save_records)
end

describe "when setting touch to false" do
before { @touch = false }

it "does not touch the updated_at" do
refute_change(-> { fake_records(:first).reload.updated_at }) { save_records }
end
end
end

#
# Scenarios in which nothing happens
#

describe "when given an empty Array" do
before { @saves = [] }

it "returns the Array" do
assert_equal(@saves, save_records)
end
end

describe "when given only records without changes" do
before { @saves = [fake_records(:first), fake_records(:third)] }

it "returns the Array" do
assert_equal(@saves, save_records)
end
end

describe "when given an empty ActiveRecord::Relation" do
before { @saves = FakeRecord.none }

it "returns the relation" do
assert_equal(@saves, save_records)
end
end

#
# Scenarios in which an exception is raised
#

describe "when given an destroyed record" do
before { @saves = [fake_records(:first).tap(&:destroy!)] }

it "raises an exception" do
error = assert_raises(::ActiveRecord::ActiveRecordError) { save_records }
assert_equal("cannot save a destroyed record", error.message)
end
end

describe "when given an Array of invalid datatypes" do
before { @saves = [Integer] }

it "raises an exception" do
error = assert_raises(::TypeError) { save_records }
assert_equal("expected ActiveRecord::Base, got Integer", error.message)
end
end

describe "when given an invalid datatype" do
before { @saves = Integer }

it "raises an exception" do
error = assert_raises(::TypeError) { save_records }
assert_equal("expected ActiveRecord::Base, got Integer", error.message)
end
end
end
end
end
4 changes: 2 additions & 2 deletions test/activerecord/bulk_update_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ def update_records
end

it "raises an exception" do
error = assert_raises(::ActiveRecord::ActiveRecordError) { update_records }
assert_equal("cannot bulk update a model without primary_key", error.message)
error = assert_raises(::ActiveRecord::UnknownPrimaryKey) { update_records }
assert_match(/Unknown primary key for table phony_records in model #<PhonyRecord::ActiveRecord_Relation:0x.+>\./, error.message)
end
end

Expand Down
16 changes: 9 additions & 7 deletions test/support/assert_change.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
# frozen_string_literal: true

class MiniTest::Test
def assert_change(test_proc, from: nil, to: nil, by: nil, &block)
raise ArgumentError, "'from' and 'to' values must differ" if !from.nil? && from == to
def assert_change(test_proc, options = {}, &block)
if options.key?(:from) && options.key?(:to) && options[:from] == options[:to]
raise ArgumentError, "'from' and 'to' values must differ"
end

before = test_proc.call
assert_equal(from, before) if from
assert_equal(options[:from], before) if options.key?(:from)
yield
after = test_proc.call
assert_equal(to, after) if to
assert_equal(by, after - before) if by
assert_equal(options[:to], after) if options.key?(:to)
assert_equal(options[:by], after - before) if options.key?(:by)
refute_equal(before, after) # rubocop:disable Rails/RefuteMethods
end

def refute_change(test_proc, from: nil, &block)
def refute_change(test_proc, options = {}, &block)
before = test_proc.call
assert_equal(from, before) if from
assert_equal(options[:from], before) if options.key?(:from)
yield
after = test_proc.call
assert_equal(before, after)
Expand Down