activerecord-batch_touching 1.0.pre.beta

Sign up to get free protection for your applications and to get access to all the features.
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