1# frozen_string_literal: true
2
3require 'spec_helper'
4
5RSpec.describe BulkInsertableAssociations do
6  class BulkFoo < ApplicationRecord
7    include BulkInsertSafe
8
9    self.table_name = '_test_bulk_foos'
10
11    validates :name, presence: true
12  end
13
14  class BulkBar < ApplicationRecord
15    include BulkInsertSafe
16
17    self.table_name = '_test_bulk_bars'
18  end
19
20  SimpleBar = Class.new(ApplicationRecord) do
21    self.table_name = '_test_simple_bars'
22  end
23
24  class BulkParent < ApplicationRecord
25    include BulkInsertableAssociations
26
27    self.table_name = '_test_bulk_parents'
28
29    has_many :bulk_foos, class_name: 'BulkFoo'
30    has_many :bulk_hunks, class_name: 'BulkFoo'
31    has_many :bulk_bars, class_name: 'BulkBar'
32    has_many :simple_bars, class_name: 'SimpleBar' # not `BulkInsertSafe`
33    has_one :bulk_foo # not supported
34  end
35
36  before(:all) do
37    ActiveRecord::Schema.define do
38      create_table :_test_bulk_parents, force: true do |t|
39        t.string :name, null: true
40      end
41
42      create_table :_test_bulk_foos, force: true do |t|
43        t.string :name, null: true
44        t.belongs_to :bulk_parent, null: false
45      end
46
47      create_table :_test_bulk_bars, force: true do |t|
48        t.string :name, null: true
49        t.belongs_to :bulk_parent, null: false
50      end
51
52      create_table :_test_simple_bars, force: true do |t|
53        t.string :name, null: true
54        t.belongs_to :bulk_parent, null: false
55      end
56    end
57  end
58
59  after(:all) do
60    ActiveRecord::Schema.define do
61      drop_table :_test_bulk_foos, force: true
62      drop_table :_test_bulk_bars, force: true
63      drop_table :_test_simple_bars, force: true
64      drop_table :_test_bulk_parents, force: true
65    end
66  end
67
68  context 'saving bulk insertable associations' do
69    let(:parent) { BulkParent.new(name: 'parent') }
70
71    context 'when items already have IDs' do
72      it 'stores nothing and raises an error' do
73        build_items(parent: parent) { |n, item| item.id = n }
74
75        expect { save_with_bulk_inserts(parent) }.to raise_error(BulkInsertSafe::PrimaryKeySetError)
76        expect(BulkFoo.count).to eq(0)
77      end
78    end
79
80    context 'when items have no IDs set' do
81      it 'stores them all and updates items with IDs' do
82        items = build_items(parent: parent)
83
84        expect(BulkFoo).to receive(:bulk_insert!).once.and_call_original
85        expect { save_with_bulk_inserts(parent) }.to change { BulkFoo.count }.from(0).to(items.size)
86        expect(parent.bulk_foos.pluck(:id)).to all(be_a Integer)
87      end
88    end
89
90    context 'when items are empty' do
91      it 'does nothing' do
92        expect(parent.bulk_foos).to be_empty
93
94        expect { save_with_bulk_inserts(parent) }.not_to change { BulkFoo.count }
95      end
96    end
97
98    context 'when relation name does not match class name' do
99      it 'stores them all' do
100        items = build_items(parent: parent, relation: :bulk_hunks)
101
102        expect(BulkFoo).to receive(:bulk_insert!).once.and_call_original
103
104        expect { save_with_bulk_inserts(parent) }.to(
105          change { BulkFoo.count }.from(0).to(items.size)
106        )
107      end
108    end
109
110    context 'with multiple threads' do
111      it 'isolates bulk insert behavior between threads' do
112        total_item_count = 10
113        parent1 = BulkParent.new(name: 'parent1')
114        parent2 = BulkParent.new(name: 'parent2')
115        build_items(parent: parent1, count: total_item_count / 2)
116        build_items(parent: parent2, count: total_item_count / 2)
117
118        expect(BulkFoo).to receive(:bulk_insert!).once.and_call_original
119        [
120          Thread.new do
121            save_with_bulk_inserts(parent1)
122          end,
123          Thread.new do
124            parent2.save!
125          end
126        ].map(&:join)
127
128        expect(BulkFoo.count).to eq(total_item_count)
129      end
130    end
131
132    context 'with multiple associations' do
133      it 'isolates writes between associations' do
134        items1 = build_items(parent: parent, relation: :bulk_foos)
135        items2 = build_items(parent: parent, relation: :bulk_bars)
136
137        expect(BulkFoo).to receive(:bulk_insert!).once.and_call_original
138        expect(BulkBar).to receive(:bulk_insert!).once.and_call_original
139
140        expect { save_with_bulk_inserts(parent) }.to(
141          change { BulkFoo.count }.from(0).to(items1.size)
142        .and(
143          change { BulkBar.count }.from(0).to(items2.size)
144        ))
145      end
146    end
147
148    context 'passing bulk insert arguments' do
149      it 'disables validations on target association' do
150        items = build_items(parent: parent)
151
152        expect(BulkFoo).to receive(:bulk_insert!).with(items, validate: false).and_return true
153
154        save_with_bulk_inserts(parent)
155      end
156    end
157
158    it 'can disable bulk-inserts within a bulk-insert block' do
159      parent1 = BulkParent.new(name: 'parent1')
160      parent2 = BulkParent.new(name: 'parent2')
161      _items1 = build_items(parent: parent1)
162      items2 = build_items(parent: parent2)
163
164      expect(BulkFoo).to receive(:bulk_insert!).once.with(items2, validate: false)
165
166      BulkInsertableAssociations.with_bulk_insert(enabled: true) do
167        BulkInsertableAssociations.with_bulk_insert(enabled: false) do
168          parent1.save!
169        end
170
171        parent2.save!
172      end
173    end
174
175    context 'when association is not bulk-insert safe' do
176      it 'saves it normally' do
177        parent.simple_bars.build
178
179        expect(SimpleBar).not_to receive(:bulk_insert!)
180        expect { save_with_bulk_inserts(parent) }.to change { SimpleBar.count }.from(0).to(1)
181      end
182    end
183
184    context 'when association is not has_many' do
185      it 'saves it normally' do
186        parent.bulk_foo = BulkFoo.new(name: 'item')
187
188        expect(BulkFoo).not_to receive(:bulk_insert!)
189        expect { save_with_bulk_inserts(parent) }.to change { BulkFoo.count }.from(0).to(1)
190      end
191    end
192
193    context 'when an item is not valid' do
194      describe '.save' do
195        it 'invalidates the parent and returns false' do
196          build_invalid_items(parent: parent)
197
198          expect(BulkInsertableAssociations.with_bulk_insert { parent.save }).to be false # rubocop:disable Rails/SaveBang
199          expect(parent.errors[:bulk_foos].size).to eq(1)
200
201          expect(BulkFoo.count).to eq(0)
202          expect(BulkParent.count).to eq(0)
203        end
204      end
205
206      describe '.save!' do
207        it 'invalidates the parent and raises error' do
208          build_invalid_items(parent: parent)
209
210          expect { save_with_bulk_inserts(parent) }.to raise_error(ActiveRecord::RecordInvalid)
211          expect(parent.errors[:bulk_foos].size).to eq(1)
212
213          expect(BulkFoo.count).to eq(0)
214          expect(BulkParent.count).to eq(0)
215        end
216      end
217    end
218  end
219
220  private
221
222  def save_with_bulk_inserts(entity)
223    BulkInsertableAssociations.with_bulk_insert { entity.save! }
224  end
225
226  def build_items(parent:, relation: :bulk_foos, count: 10)
227    count.times do |n|
228      item = parent.send(relation).build(name: "item_#{n}", bulk_parent_id: parent.id)
229      yield(n, item) if block_given?
230    end
231    parent.send(relation)
232  end
233
234  def build_invalid_items(parent:)
235    build_items(parent: parent).tap do |items|
236      invalid_item = items.first
237      invalid_item.name = nil
238      expect(invalid_item).not_to be_valid
239    end
240  end
241end
242