activerecord-batch_touching 1.0.pre.beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +4 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/activerecord-batch_touching.gemspec +30 -0
- data/lib/activerecord/batch_touching/state.rb +54 -0
- data/lib/activerecord/batch_touching/version.rb +5 -0
- data/lib/activerecord/batch_touching.rb +140 -0
- data/spec/active_record/batch_touching_spec.rb +369 -0
- data/spec/rcov_exclude_list.rb +3 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/models.rb +9 -0
- data/spec/support/schema.rb +27 -0
- metadata +176 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 829e262f5bb2081c84c1d8baea4afc937806119fbc605a0f13b5bccaecbf9616
|
4
|
+
data.tar.gz: ed048b0b157de0f9e6dbf6dcdfe08253c95177759e692fe718eda84f7460d0d5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 625f5ed7a40621a366cd5c110a23d7f1a4048b92d7568feec17206c6f72d3507a6d4f4a1862f472147e980f0b632420ff0afe70372c297716718ec00b6c6671f
|
7
|
+
data.tar.gz: 52ba63a3c2df1cb8431ab5f17703cf51021250bba311bdcd483657ae24f4ebef5a86299c0a9e84b8869a08a08821b6188e417cdb08f62f09d9d8ec482208d725
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2022 Phil Phillips
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'activerecord/batch_touching/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "activerecord-batch_touching"
|
8
|
+
spec.version = "1.0-beta" # Activerecord::BatchTouching::VERSION
|
9
|
+
spec.authors = ["Brian Morearty", "Phil Phillips"]
|
10
|
+
spec.email = ["phil@productplan.com"]
|
11
|
+
spec.summary = %q{Batch up your ActiveRecord "touch" operations for better performance.}
|
12
|
+
spec.description = %q{Batch up your ActiveRecord "touch" operations for better performance. All accumulated "touch" calls will be consolidated into as few database round trips as possible.}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "activerecord", ">= 6"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "sqlite3"
|
26
|
+
spec.add_development_dependency "timecop"
|
27
|
+
spec.add_development_dependency "rspec-rails"
|
28
|
+
spec.add_development_dependency "simplecov"
|
29
|
+
spec.add_development_dependency "simplecov-rcov"
|
30
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
# = Active Record Batch Touching
|
3
|
+
module BatchTouching
|
4
|
+
|
5
|
+
# Tracking of the touch state. This class has no class-level data, so you can
|
6
|
+
# store per-thread instances in thread-local variables.
|
7
|
+
class State # :nodoc:
|
8
|
+
# Return the records grouped by class and columns that were touched:
|
9
|
+
#
|
10
|
+
# {
|
11
|
+
# [Owner, [:updated_at]] => Set.new([owner1, owner2]),
|
12
|
+
# [Pet, [:neutered_at, :updated_at]] => Set.new([pet1]),
|
13
|
+
# [Pet, [:updated_at]] => Set.new([pet2])
|
14
|
+
# }
|
15
|
+
#
|
16
|
+
attr_reader :records
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@records = Hash.new { Set.new }
|
20
|
+
end
|
21
|
+
|
22
|
+
def clear_records!
|
23
|
+
@records = Hash.new { Set.new }
|
24
|
+
end
|
25
|
+
|
26
|
+
def more_records?
|
27
|
+
@records.present?
|
28
|
+
end
|
29
|
+
|
30
|
+
def add_record(record, columns)
|
31
|
+
# Include the standard updated_at column and any additional specified columns
|
32
|
+
columns += record.send(:timestamp_attributes_for_update_in_model)
|
33
|
+
columns = columns.map(&:to_sym).sort
|
34
|
+
|
35
|
+
key = [record.class, columns]
|
36
|
+
@records[key] += [record]
|
37
|
+
end
|
38
|
+
|
39
|
+
# Merge another state into this one
|
40
|
+
def merge!(other_state)
|
41
|
+
merge_records!(@records, other_state.records)
|
42
|
+
end
|
43
|
+
|
44
|
+
protected
|
45
|
+
|
46
|
+
# Merge from_records into into_records
|
47
|
+
def merge_records!(into_records, from_records)
|
48
|
+
from_records.each do |key, records|
|
49
|
+
into_records[key] += records
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require "activerecord/batch_touching/version"
|
2
|
+
require "activerecord/batch_touching/state"
|
3
|
+
|
4
|
+
module ActiveRecord
|
5
|
+
# = Active Record Batch Touching
|
6
|
+
module BatchTouching # :nodoc:
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
# Override ActiveRecord::Base#touch_later. This will effectively disable the current built-in mechanism AR uses
|
10
|
+
# to delay touching in favor of our method of batch touching.
|
11
|
+
def touch_later(*names)
|
12
|
+
touch(*names)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Override ActiveRecord::Base#touch. If currently batching touches, always return
|
16
|
+
# true because there's no way to tell if the write would have failed.
|
17
|
+
def touch(*names, time: nil)
|
18
|
+
if BatchTouching.batch_touching? && !no_touching?
|
19
|
+
add_to_transaction
|
20
|
+
BatchTouching.add_record(self, names)
|
21
|
+
true
|
22
|
+
else
|
23
|
+
super
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# These get added as class methods to ActiveRecord::Base.
|
28
|
+
module ClassMethods
|
29
|
+
# Batches up +touch+ calls for the duration of a transaction.
|
30
|
+
# +after_touch+ callbacks are also delayed until the transaction is committed.
|
31
|
+
#
|
32
|
+
# ==== Examples
|
33
|
+
#
|
34
|
+
# # Touches Person.first and Person.last in a single database round-trip.
|
35
|
+
# Person.transaction do
|
36
|
+
# Person.first.touch
|
37
|
+
# Person.last.touch
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# # Touches Person.first once, not twice, right before the transaction is committed.
|
41
|
+
# Person.transaction do
|
42
|
+
# Person.first.touch
|
43
|
+
# Person.first.touch
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
def transaction(**options, &block)
|
47
|
+
super(**options) do
|
48
|
+
BatchTouching.start(**options, &block)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class << self
|
54
|
+
def states
|
55
|
+
Thread.current[:batch_touching_states] ||= []
|
56
|
+
end
|
57
|
+
|
58
|
+
def current_state
|
59
|
+
states.last
|
60
|
+
end
|
61
|
+
|
62
|
+
delegate :add_record, to: :current_state
|
63
|
+
|
64
|
+
def batch_touching?
|
65
|
+
states.present?
|
66
|
+
end
|
67
|
+
|
68
|
+
# Start batching all touches. When done, apply them. (Unless nested.)
|
69
|
+
def start(options = {})
|
70
|
+
states.push State.new
|
71
|
+
yield.tap do
|
72
|
+
apply_touches if states.length == 1
|
73
|
+
end
|
74
|
+
ensure
|
75
|
+
merge_transactions unless $! && options[:requires_new]
|
76
|
+
|
77
|
+
# Decrement nesting even if +apply_touches+ raised an error. To ensure the stack of States
|
78
|
+
# is empty after the top-level transaction exits.
|
79
|
+
states.pop
|
80
|
+
end
|
81
|
+
|
82
|
+
# When exiting a nested transaction, merge the nested transaction's
|
83
|
+
# touched records with the outer transaction's touched records.
|
84
|
+
def merge_transactions
|
85
|
+
states[-2].merge!(current_state) if states.length > 1
|
86
|
+
end
|
87
|
+
|
88
|
+
# Apply the touches that were batched. We're in a transaction already so there's no need to open one.
|
89
|
+
def apply_touches
|
90
|
+
callbacks_run = Set.new
|
91
|
+
all_states = State.new
|
92
|
+
while current_state.more_records?
|
93
|
+
all_states.merge!(current_state)
|
94
|
+
state_records = current_state.records
|
95
|
+
current_state.clear_records!
|
96
|
+
state_records.each do |_, records|
|
97
|
+
# Run callbacks to collect more touches (i.e. touch: true for associations)
|
98
|
+
records.each do |record|
|
99
|
+
unless callbacks_run.include?(record)
|
100
|
+
record._run_touch_callbacks
|
101
|
+
callbacks_run.add(record)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Sort by class name. Having a consistent order can help mitigate deadlocks.
|
108
|
+
sorted_records = all_states.records.keys.sort_by { |k| k.first.name }.map { |k| [k, all_states.records[k]] }.to_h
|
109
|
+
sorted_records.each do |(klass, columns), records|
|
110
|
+
touch_records klass, columns, records
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Touch the specified records--non-empty set of instances of the same class.
|
115
|
+
def touch_records(klass, columns, records)
|
116
|
+
if columns.present?
|
117
|
+
current_time = records.first.send(:current_time_from_proper_timezone)
|
118
|
+
|
119
|
+
records.each do |record|
|
120
|
+
record.instance_eval do
|
121
|
+
columns.each { |column| write_attribute column, current_time }
|
122
|
+
if locking_enabled?
|
123
|
+
self[self.class.locking_column] += 1
|
124
|
+
clear_attribute_change(self.class.locking_column)
|
125
|
+
end
|
126
|
+
clear_attribute_changes(columns)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
sql = columns.map { |column| "#{klass.connection.quote_column_name(column)} = :current_time" }.join(", ")
|
131
|
+
sql += ", #{klass.locking_column} = #{klass.locking_column} + 1" if klass.locking_enabled?
|
132
|
+
|
133
|
+
klass.unscoped.where(klass.primary_key => records.to_a).update_all([sql, current_time: current_time])
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
ActiveRecord::Base.include ActiveRecord::BatchTouching
|
@@ -0,0 +1,369 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Activerecord::BatchTouching do
|
4
|
+
let!(:owner) { Owner.create name: "Rosey" }
|
5
|
+
let!(:pet1) { Pet.create(name: "Bones", owner: owner) }
|
6
|
+
let!(:pet2) { Pet.create(name: "Ema", owner: owner) }
|
7
|
+
let!(:car) { Car.create(name: "Ferrari", lock_version: 1) }
|
8
|
+
|
9
|
+
it 'has a version number' do
|
10
|
+
expect(Activerecord::BatchTouching::VERSION).not_to be nil
|
11
|
+
end
|
12
|
+
|
13
|
+
it "touch returns true when not in a batch_touching block" do
|
14
|
+
expect(owner.touch).to equal(true)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "touch returns true in a batch_touching block" do
|
18
|
+
ActiveRecord::Base.transaction do
|
19
|
+
expect(owner.touch).to equal(true)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it "consolidates touches on a single record when inside a transaction" do
|
24
|
+
expect_updates [{ "owners" => { ids: owner } }] do
|
25
|
+
ActiveRecord::Base.transaction do
|
26
|
+
owner.touch
|
27
|
+
owner.touch
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
it "calls touch callbacks just once when there are multiple touches" do
|
33
|
+
expect(owner).to receive(:_run_touch_callbacks).once.and_call_original
|
34
|
+
ActiveRecord::Base.transaction do
|
35
|
+
owner.touch
|
36
|
+
owner.touch
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'sets updated_at on the in-memory instance when it eventually touches the record' do
|
41
|
+
original_time = new_time = nil
|
42
|
+
|
43
|
+
Timecop.freeze(2014, 7, 4, 12, 0, 0) do
|
44
|
+
original_time = Time.current
|
45
|
+
owner.touch
|
46
|
+
end
|
47
|
+
|
48
|
+
Timecop.freeze(2014, 7, 10, 12, 0, 0) do
|
49
|
+
new_time = Time.current
|
50
|
+
ActiveRecord::Base.transaction do
|
51
|
+
owner.touch
|
52
|
+
expect(owner.updated_at).to eq(original_time)
|
53
|
+
expect(owner.changed?).to be_falsey
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
expect(owner.updated_at).to eq(new_time)
|
58
|
+
expect(owner.changed?).to be_falsey
|
59
|
+
end
|
60
|
+
|
61
|
+
it "does not mark the instance as changed when touch is called" do
|
62
|
+
ActiveRecord::Base.transaction do
|
63
|
+
owner.touch
|
64
|
+
expect(owner).not_to be_changed
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
it "does not mark the instance as changed, even if its lock_version is incremented" do
|
69
|
+
ActiveRecord::Base.transaction do
|
70
|
+
car.touch
|
71
|
+
end
|
72
|
+
expect(car).not_to be_changed
|
73
|
+
end
|
74
|
+
|
75
|
+
it "consolidates touches for all instances in a single table" do
|
76
|
+
expect_updates [{ "pets" => { ids: [pet1, pet2] } }, "owners" => { ids: owner }] do
|
77
|
+
ActiveRecord::Base.transaction do
|
78
|
+
pet1.touch
|
79
|
+
pet2.touch
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
it "does nothing if no_touching is on" do
|
85
|
+
expect(owner).to receive(:_run_touch_callbacks).never
|
86
|
+
expect_updates [] do
|
87
|
+
ActiveRecord::Base.no_touching do
|
88
|
+
ActiveRecord::Base.transaction do
|
89
|
+
owner.touch
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
it "only applies touches for which no_touching is off" do
|
96
|
+
expect(owner).to receive(:_run_touch_callbacks).never
|
97
|
+
expect(pet1).to receive(:_run_touch_callbacks).once.and_call_original
|
98
|
+
expect_updates ["pets" => { ids: pet1 }] do
|
99
|
+
Owner.no_touching do
|
100
|
+
ActiveRecord::Base.transaction do
|
101
|
+
owner.touch
|
102
|
+
pet1.touch
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
it "does not apply nested touches if no_touching was turned on inside batch_touching" do
|
109
|
+
expect(owner).to receive(:_run_touch_callbacks).once.and_call_original
|
110
|
+
expect(pet1).to receive(:_run_touch_callbacks).never
|
111
|
+
expect_updates ["owners" => { ids: owner }] do
|
112
|
+
ActiveRecord::Base.transaction do
|
113
|
+
owner.touch
|
114
|
+
ActiveRecord::Base.no_touching do
|
115
|
+
pet1.touch
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
it "can update nonstandard columns" do
|
122
|
+
expect_updates ["owners" => { ids: owner, columns: ["updated_at", "happy_at"] }] do
|
123
|
+
ActiveRecord::Base.transaction do
|
124
|
+
owner.touch :happy_at
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
it "treats string column names and symbol column names as the same" do
|
130
|
+
expect_updates ["owners" => { ids: owner, columns: ["updated_at", "happy_at"] }] do
|
131
|
+
ActiveRecord::Base.transaction do
|
132
|
+
owner.touch :happy_at
|
133
|
+
owner.touch "happy_at"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
it "splits up nonstandard column touches and standard column touches" do
|
139
|
+
owner2 = Owner.create name: "Guybrush"
|
140
|
+
|
141
|
+
expect_updates [{ "owners" => { ids: owner, columns: ["updated_at", "happy_at"] } },
|
142
|
+
{ "owners" => { ids: owner2, columns: ["updated_at"] } }] do
|
143
|
+
|
144
|
+
ActiveRecord::Base.transaction do
|
145
|
+
owner.touch :happy_at
|
146
|
+
owner2.touch
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
it "can update multiple nonstandard columns of a single record in different calls to touch" do
|
152
|
+
expect_updates [{ "owners" => { ids: owner, columns: ["updated_at", "happy_at"] } },
|
153
|
+
{ "owners" => { ids: owner, columns: ["updated_at", "sad_at"] } }] do
|
154
|
+
|
155
|
+
ActiveRecord::Base.transaction do
|
156
|
+
owner.touch :happy_at
|
157
|
+
owner.touch :sad_at
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
it "can update multiple nonstandard columns of a single record in a single call to touch" do
|
163
|
+
expect_updates [{ "owners" => { ids: owner, columns: [ "updated_at", "happy_at", "sad_at"] } }] do
|
164
|
+
|
165
|
+
ActiveRecord::Base.transaction do
|
166
|
+
owner.touch :happy_at, :sad_at
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
it "consolidates touch: true touches" do
|
172
|
+
expect_updates [{ "pets" => { ids: [pet1, pet2] } }, { "owners" => { ids: owner } }] do
|
173
|
+
ActiveRecord::Base.transaction do
|
174
|
+
pet1.touch
|
175
|
+
pet2.touch
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
it "does not touch the owning record via touch: true if it was already touched explicitly" do
|
181
|
+
expect(owner).to receive(:_run_touch_callbacks).once.and_call_original
|
182
|
+
expect(pet1).to receive(:_run_touch_callbacks).once.and_call_original
|
183
|
+
expect(pet2).to receive(:_run_touch_callbacks).once.and_call_original
|
184
|
+
|
185
|
+
expect_updates [{ "pets" => { ids: [pet1, pet2] } }, { "owners" => { ids: owner } }] do
|
186
|
+
ActiveRecord::Base.transaction do
|
187
|
+
owner.touch
|
188
|
+
pet1.touch
|
189
|
+
pet2.touch
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
it "does not consolidate touches when outside a transaction" do
|
195
|
+
expect_updates [{ "owners" => { ids: owner } },
|
196
|
+
{ "owners" => { ids: owner } }] do
|
197
|
+
owner.touch
|
198
|
+
owner.touch
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
it "nested transactions get consolidated into a single set of touches" do
|
203
|
+
expect(owner).to receive(:_run_touch_callbacks).once.and_call_original
|
204
|
+
expect(pet1).to receive(:_run_touch_callbacks).once.and_call_original
|
205
|
+
expect(pet2).to receive(:_run_touch_callbacks).once.and_call_original
|
206
|
+
|
207
|
+
expect_updates [{ "pets" => { ids: [pet1, pet2] } }, { "owners" => { ids: owner } }] do
|
208
|
+
ActiveRecord::Base.transaction do
|
209
|
+
pet1.touch
|
210
|
+
ActiveRecord::Base.transaction do
|
211
|
+
pet2.touch
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
it "rolling back from a nested transaction without :requires_new touches the records in the inner transaction" do
|
218
|
+
expect_updates [{ "pets" => { ids: [pet1, pet2] } }, { "owners" => { ids: owner } }] do
|
219
|
+
ActiveRecord::Base.transaction do
|
220
|
+
pet1.touch
|
221
|
+
ActiveRecord::Base.transaction do
|
222
|
+
pet2.touch
|
223
|
+
raise ActiveRecord::Rollback
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
it "rolling back from a nested transaction with :requires_new does not touch the records in the inner transaction" do
|
230
|
+
expect_updates [{ "pets" => { ids: pet1 } }, { "owners" => { ids: owner } }] do
|
231
|
+
ActiveRecord::Base.transaction do
|
232
|
+
pet1.touch
|
233
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
234
|
+
pet2.touch
|
235
|
+
raise ActiveRecord::Rollback
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
it "touching a record in an outer and inner new transaction, then rolling back the inner one, still touches the record" do
|
242
|
+
expect_updates [{ "pets" => { ids: pet1 } }, { "owners" => { ids: owner } }] do
|
243
|
+
ActiveRecord::Base.transaction do
|
244
|
+
pet1.touch
|
245
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
246
|
+
pet1.touch
|
247
|
+
raise ActiveRecord::Rollback
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
it "rolling back from an outer transaction does not touch any records" do
|
254
|
+
expect_updates [] do
|
255
|
+
ActiveRecord::Base.transaction do
|
256
|
+
pet1.touch
|
257
|
+
ActiveRecord::Base.transaction do
|
258
|
+
pet2.touch :neutered_at
|
259
|
+
end
|
260
|
+
raise ActiveRecord::Rollback
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
it "consolidates touch: :column_name touches" do
|
266
|
+
pet_klass = Class.new(ActiveRecord::Base) do
|
267
|
+
def self.name; 'Pet'; end
|
268
|
+
belongs_to :owner, :touch => :happy_at
|
269
|
+
after_touch :after_touch_callback
|
270
|
+
def after_touch_callback; end
|
271
|
+
end
|
272
|
+
|
273
|
+
pet = pet_klass.first
|
274
|
+
owner = pet.owner
|
275
|
+
|
276
|
+
expect_updates [{ "owners" => { ids: owner, columns: ["updated_at", "happy_at"] } }, { "pets" => { ids: pet } }] do
|
277
|
+
ActiveRecord::Base.transaction do
|
278
|
+
pet.touch
|
279
|
+
pet.touch
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
it "keeps iterating as long as after_touch keeps causing more records to be touched" do
|
285
|
+
pet_klass = Class.new(ActiveRecord::Base) do
|
286
|
+
def self.name; 'Pet'; end
|
287
|
+
belongs_to :owner
|
288
|
+
|
289
|
+
# Touch the owner in after_touch instead of using touch: true
|
290
|
+
after_touch :touch_owner
|
291
|
+
def touch_owner; owner.touch; end
|
292
|
+
end
|
293
|
+
|
294
|
+
pet = pet_klass.first
|
295
|
+
owner = pet.owner
|
296
|
+
|
297
|
+
expect_updates [{ "owners" => { ids: owner } }, { "pets" => { ids: pet } }] do
|
298
|
+
ActiveRecord::Base.transaction do
|
299
|
+
pet.touch
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
it "increments the optimistic lock column in memory and in the DB" do
|
305
|
+
car1 = Car.create(name: "Ferrari", lock_version: 1)
|
306
|
+
car2 = Car.create(name: "Lambo", lock_version: 2)
|
307
|
+
|
308
|
+
ActiveRecord::Base.transaction do
|
309
|
+
car1.touch
|
310
|
+
car2.touch
|
311
|
+
end
|
312
|
+
|
313
|
+
expect(car1.lock_version).to equal(2)
|
314
|
+
expect(car2.lock_version).to equal(3)
|
315
|
+
|
316
|
+
expect(car1.reload.lock_version).to equal(2)
|
317
|
+
expect(car2.reload.lock_version).to equal(3)
|
318
|
+
end
|
319
|
+
|
320
|
+
private
|
321
|
+
|
322
|
+
def expect_updates(tables_ids_and_columns)
|
323
|
+
expected_sql = expected_sql_for(tables_ids_and_columns)
|
324
|
+
expect(ActiveRecord::Base.connection).to receive(:update).exactly(expected_sql.length).times do |stmt, _, _|
|
325
|
+
if stmt.to_sql =~ /UPDATE /i
|
326
|
+
index = expected_sql.index { |expected_stmt| stmt.to_sql =~ expected_stmt }
|
327
|
+
expect(index).to be, "An unexpected touch occurred: #{stmt.to_sql}"
|
328
|
+
expected_sql.delete_at(index)
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
yield
|
333
|
+
|
334
|
+
expect(expected_sql).to be_empty, "Some of the expected updates were not executed."
|
335
|
+
end
|
336
|
+
|
337
|
+
# Creates an array of regular expressions to match the SQL statements that we expect
|
338
|
+
# to execute.
|
339
|
+
#
|
340
|
+
# Each element in the tables_ids_and_columns array is in this form:
|
341
|
+
#
|
342
|
+
# { "table_name" => { ids: id_or_array_of_ids, columns: column_name_or_array } }
|
343
|
+
#
|
344
|
+
# 'columns' is optional. If it's missing it is assumed that "updated_at" is the only
|
345
|
+
# column that gets touched.
|
346
|
+
def expected_sql_for(tables_ids_and_columns)
|
347
|
+
tables_ids_and_columns.map do |entry|
|
348
|
+
entry.map do |table, options|
|
349
|
+
ids = Array.wrap(options[:ids])
|
350
|
+
columns = Array.wrap(options[:columns]).presence || ["updated_at"]
|
351
|
+
columns = columns.sort
|
352
|
+
Regexp.new(touch_sql(table, columns, ids))
|
353
|
+
end
|
354
|
+
end.flatten
|
355
|
+
end
|
356
|
+
|
357
|
+
# in: array of records or record ids
|
358
|
+
# out: "( = 1|= \?|= \$1)" or " IN (1, 2)"
|
359
|
+
#
|
360
|
+
# In some cases, such as SQLite3 when outside a transaction, the logged SQL uses ? instead of record ids.
|
361
|
+
def ids_sql(ids)
|
362
|
+
ids = ids.map { |id| id.class.respond_to?(:primary_key) ? id.send(id.class.primary_key) : id }
|
363
|
+
ids.length > 1 ? %{ IN \\(#{Array.new(ids.length, '\?').join(", ")}\\)} : %{( = #{ids.first}|= \\?|= \\$1)}
|
364
|
+
end
|
365
|
+
|
366
|
+
def touch_sql(table, columns, ids)
|
367
|
+
%{UPDATE \\"#{table}"\\ SET #{columns.map { |column| %{\\"#{column}\\" =.+} }.join(", ") } .+#{ids_sql(ids)}\\Z}
|
368
|
+
end
|
369
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
if ENV["COVERAGE"]
|
2
|
+
require_relative 'rcov_exclude_list.rb'
|
3
|
+
exlist = Dir.glob(@exclude_list)
|
4
|
+
require 'simplecov'
|
5
|
+
require 'simplecov-rcov'
|
6
|
+
SimpleCov.formatter = SimpleCov::Formatter::RcovFormatter
|
7
|
+
SimpleCov.start do
|
8
|
+
exlist.each do |p|
|
9
|
+
add_filter p
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
15
|
+
require 'active_record'
|
16
|
+
require 'activerecord/batch_touching'
|
17
|
+
require 'timecop'
|
18
|
+
|
19
|
+
ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
|
20
|
+
|
21
|
+
load File.dirname(__FILE__) + '/support/schema.rb'
|
22
|
+
require File.dirname(__FILE__) + '/support/models.rb'
|
@@ -0,0 +1,27 @@
|
|
1
|
+
ActiveRecord::Schema.define do
|
2
|
+
self.verbose = false
|
3
|
+
|
4
|
+
create_table :owners, :force => true do |t|
|
5
|
+
t.string :name
|
6
|
+
|
7
|
+
t.timestamps
|
8
|
+
t.datetime :happy_at
|
9
|
+
t.datetime :sad_at
|
10
|
+
end
|
11
|
+
|
12
|
+
create_table :pets, :force => true do |t|
|
13
|
+
t.string :name
|
14
|
+
t.integer :owner_id
|
15
|
+
t.datetime :neutered_at
|
16
|
+
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table :cars, force: true do |t|
|
21
|
+
t.string :name
|
22
|
+
t.column :lock_version, :integer, null: false, default: 0
|
23
|
+
|
24
|
+
t.timestamps
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,176 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord-batch_touching
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.pre.beta
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brian Morearty
|
8
|
+
- Phil Phillips
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2022-07-01 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activerecord
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '6'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '6'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: bundler
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - ">="
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '0'
|
35
|
+
type: :development
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '0'
|
42
|
+
- !ruby/object:Gem::Dependency
|
43
|
+
name: rake
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '0'
|
49
|
+
type: :development
|
50
|
+
prerelease: false
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
- !ruby/object:Gem::Dependency
|
57
|
+
name: sqlite3
|
58
|
+
requirement: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0'
|
63
|
+
type: :development
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: timecop
|
72
|
+
requirement: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
- !ruby/object:Gem::Dependency
|
85
|
+
name: rspec-rails
|
86
|
+
requirement: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
type: :development
|
92
|
+
prerelease: false
|
93
|
+
version_requirements: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
- !ruby/object:Gem::Dependency
|
99
|
+
name: simplecov
|
100
|
+
requirement: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
type: :development
|
106
|
+
prerelease: false
|
107
|
+
version_requirements: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
- !ruby/object:Gem::Dependency
|
113
|
+
name: simplecov-rcov
|
114
|
+
requirement: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
type: :development
|
120
|
+
prerelease: false
|
121
|
+
version_requirements: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
description: Batch up your ActiveRecord "touch" operations for better performance.
|
127
|
+
All accumulated "touch" calls will be consolidated into as few database round trips
|
128
|
+
as possible.
|
129
|
+
email:
|
130
|
+
- phil@productplan.com
|
131
|
+
executables: []
|
132
|
+
extensions: []
|
133
|
+
extra_rdoc_files: []
|
134
|
+
files:
|
135
|
+
- ".gitignore"
|
136
|
+
- ".rspec"
|
137
|
+
- Gemfile
|
138
|
+
- LICENSE
|
139
|
+
- activerecord-batch_touching.gemspec
|
140
|
+
- lib/activerecord/batch_touching.rb
|
141
|
+
- lib/activerecord/batch_touching/state.rb
|
142
|
+
- lib/activerecord/batch_touching/version.rb
|
143
|
+
- spec/active_record/batch_touching_spec.rb
|
144
|
+
- spec/rcov_exclude_list.rb
|
145
|
+
- spec/spec_helper.rb
|
146
|
+
- spec/support/models.rb
|
147
|
+
- spec/support/schema.rb
|
148
|
+
homepage: ''
|
149
|
+
licenses:
|
150
|
+
- MIT
|
151
|
+
metadata: {}
|
152
|
+
post_install_message:
|
153
|
+
rdoc_options: []
|
154
|
+
require_paths:
|
155
|
+
- lib
|
156
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
157
|
+
requirements:
|
158
|
+
- - ">="
|
159
|
+
- !ruby/object:Gem::Version
|
160
|
+
version: '0'
|
161
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
162
|
+
requirements:
|
163
|
+
- - ">"
|
164
|
+
- !ruby/object:Gem::Version
|
165
|
+
version: 1.3.1
|
166
|
+
requirements: []
|
167
|
+
rubygems_version: 3.3.7
|
168
|
+
signing_key:
|
169
|
+
specification_version: 4
|
170
|
+
summary: Batch up your ActiveRecord "touch" operations for better performance.
|
171
|
+
test_files:
|
172
|
+
- spec/active_record/batch_touching_spec.rb
|
173
|
+
- spec/rcov_exclude_list.rb
|
174
|
+
- spec/spec_helper.rb
|
175
|
+
- spec/support/models.rb
|
176
|
+
- spec/support/schema.rb
|