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 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
@@ -0,0 +1,4 @@
1
+ .idea
2
+ .ruby-version
3
+ Gemfile.lock
4
+ coverage
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ --color
2
+ --tty
3
+ --format documentation
4
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in activerecord-batch_touching.gemspec
4
+ gemspec
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,5 @@
1
+ module Activerecord
2
+ module BatchTouching
3
+ VERSION = "1.0"
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ @exclude_list = [
2
+ 'spec/**/*.rb'
3
+ ]
@@ -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,9 @@
1
+ class Owner < ActiveRecord::Base
2
+ has_many :pets, inverse_of: :owner
3
+ end
4
+
5
+ class Pet < ActiveRecord::Base
6
+ belongs_to :owner, touch: true, inverse_of: :pets
7
+ end
8
+
9
+ class Car < ActiveRecord::Base; end
@@ -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