├── spec ├── rcov_exclude_list.rb ├── support │ ├── models.rb │ └── schema.rb ├── spec_helper.rb └── activerecord │ └── delay_touching_spec.rb ├── .rspec ├── Gemfile ├── lib └── activerecord │ ├── delay_touching │ ├── version.rb │ └── state.rb │ └── delay_touching.rb ├── .gitignore ├── Rakefile ├── LICENSE.txt ├── activerecord-delay_touching.gemspec └── README.md /spec/rcov_exclude_list.rb: -------------------------------------------------------------------------------- 1 | @exclude_list = [ 2 | #'spec/**/*.rb' 3 | ] 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --tty 3 | --format documentation 4 | --require spec_helper 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in activerecord-delay_touching.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/activerecord/delay_touching/version.rb: -------------------------------------------------------------------------------- 1 | module Activerecord 2 | module DelayTouching 3 | VERSION = "1.1.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .idea 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | *.bundle 20 | *.so 21 | *.o 22 | *.a 23 | mkmf.log 24 | .ruby-version 25 | coverage/ 26 | results.xml 27 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core" 3 | require "rspec/core/rake_task" 4 | 5 | Rake::Task["spec"].clear 6 | RSpec::Core::RakeTask.new(:spec) do |t| 7 | t.fail_on_error = false 8 | t.rspec_opts = %w[-f JUnit -o results.xml] 9 | end 10 | 11 | desc "Run RSpec with code coverage" 12 | task :coverage do 13 | ENV['COVERAGE'] = 'true' 14 | Rake::Task["spec"].execute 15 | end 16 | task :default => :spec 17 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class Person < ActiveRecord::Base 2 | has_many :pets, inverse_of: :person 3 | end 4 | 5 | class Pet < ActiveRecord::Base 6 | belongs_to :person, touch: true, inverse_of: :pets 7 | end 8 | 9 | class Post < ActiveRecord::Base 10 | has_many :comments, dependent: :destroy 11 | end 12 | 13 | class User < ActiveRecord::Base 14 | has_many :comments, dependent: :destroy 15 | end 16 | 17 | class Comment < ActiveRecord::Base 18 | belongs_to :post, touch: true 19 | belongs_to :user, touch: true 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'yarjuf' 2 | 3 | if ENV["COVERAGE"] 4 | require_relative 'rcov_exclude_list.rb' 5 | exlist = Dir.glob(@exclude_list) 6 | require 'simplecov' 7 | require 'simplecov-rcov' 8 | SimpleCov.formatter = SimpleCov::Formatter::RcovFormatter 9 | SimpleCov.start do 10 | exlist.each do |p| 11 | add_filter p 12 | end 13 | end 14 | end 15 | 16 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 17 | require 'active_record' 18 | require 'activerecord/delay_touching' 19 | require 'timecop' 20 | 21 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 22 | 23 | load File.dirname(__FILE__) + '/support/schema.rb' 24 | require File.dirname(__FILE__) + '/support/models.rb' 25 | 26 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table :people, :force => true do |t| 5 | t.string :name 6 | 7 | t.timestamps 8 | end 9 | 10 | create_table :pets, :force => true do |t| 11 | t.string :name 12 | t.integer :person_id 13 | t.datetime :neutered_at 14 | t.datetime :fed_at 15 | 16 | t.timestamps 17 | end 18 | 19 | create_table :posts, force: true do |t| 20 | t.timestamps null: false 21 | end 22 | 23 | create_table :users, force: true do |t| 24 | t.timestamps null: false 25 | end 26 | 27 | create_table :comments, force: true do |t| 28 | t.integer :post_id 29 | t.integer :user_id 30 | t.timestamps null: false 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 GoDaddy 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /activerecord-delay_touching.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'activerecord/delay_touching/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "activerecord-delay_touching" 8 | spec.version = Activerecord::DelayTouching::VERSION 9 | spec.authors = ["GoDaddy P&C Commerce", "Brian Morearty"] 10 | spec.email = ["nemo-engg@godaddy.com", "brian@morearty.org"] 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. ActiveRecord::Base.delay_touching do ... end. When "end" is reached, 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", "~> 4.2" 22 | 23 | spec.add_development_dependency "bundler", "~> 1.6" 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", "~> 3.0" 28 | spec.add_development_dependency "simplecov" 29 | spec.add_development_dependency "simplecov-rcov" 30 | spec.add_development_dependency "yarjuf" 31 | end 32 | -------------------------------------------------------------------------------- /lib/activerecord/delay_touching/state.rb: -------------------------------------------------------------------------------- 1 | require "activerecord/delay_touching/version" 2 | 3 | module ActiveRecord 4 | module DelayTouching 5 | 6 | # Tracking of the touch state. This class has no class-level data, so you can 7 | # store per-thread instances in thread-local variables. 8 | class State 9 | attr_accessor :nesting 10 | 11 | def initialize 12 | @records = Hash.new { Set.new } 13 | @already_updated_records = Hash.new { Set.new } 14 | @nesting = 0 15 | end 16 | 17 | def updated(attr, records) 18 | @records[attr].subtract records 19 | @records.delete attr if @records[attr].empty? 20 | @already_updated_records[attr] += records 21 | end 22 | 23 | # Return the records grouped by the attributes that were touched, and by class: 24 | # [ 25 | # [ 26 | # nil, { Person => [ person1, person2 ], Pet => [ pet1 ] } 27 | # ], 28 | # [ 29 | # :neutered_at, { Pet => [ pet1 ] } 30 | # ], 31 | # ] 32 | def records_by_attrs_and_class 33 | @records.map { |attrs, records| [attrs, records.group_by(&:class)] } 34 | end 35 | 36 | # There are more records as long as there is at least one record that is persisted 37 | def more_records? 38 | @records.each do |_, set| 39 | set.each { |record| return true if record.persisted? } # will shortcut on first persisted record found 40 | end 41 | 42 | false # no persisted records found, so no more records to process 43 | end 44 | 45 | def add_record(record, *columns) 46 | columns << nil if columns.empty? #if no arguments are passed, we will use nil to infer default column 47 | columns.each do |column| 48 | @records[column] += [ record ] unless @already_updated_records[column].include?(record) 49 | end 50 | end 51 | 52 | def clear_records 53 | @records.clear 54 | @already_updated_records.clear 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/activerecord/delay_touching.rb: -------------------------------------------------------------------------------- 1 | require "activerecord/delay_touching/version" 2 | require "activerecord/delay_touching/state" 3 | 4 | module ActiveRecord 5 | module DelayTouching 6 | extend ActiveSupport::Concern 7 | 8 | # Override ActiveRecord::Base#touch. 9 | def touch(*names) 10 | if self.class.delay_touching? && !try(:no_touching?) 11 | DelayTouching.add_record(self, *names) 12 | true 13 | else 14 | super 15 | end 16 | end 17 | 18 | # These get added as class methods to ActiveRecord::Base. 19 | module ClassMethods 20 | # Lets you batch up your `touch` calls for the duration of a block. 21 | # 22 | # ==== Examples 23 | # 24 | # # Touches Person.first once, not twice, when the block exits. 25 | # ActiveRecord::Base.delay_touching do 26 | # Person.first.touch 27 | # Person.first.touch 28 | # end 29 | # 30 | def delay_touching(&block) 31 | DelayTouching.call &block 32 | end 33 | 34 | # Are we currently executing in a delay_touching block? 35 | def delay_touching? 36 | DelayTouching.state.nesting > 0 37 | end 38 | end 39 | 40 | def self.state 41 | Thread.current[:delay_touching_state] ||= State.new 42 | end 43 | 44 | class << self 45 | delegate :add_record, to: :state 46 | end 47 | 48 | # Start delaying all touches. When done, apply them. (Unless nested.) 49 | def self.call 50 | state.nesting += 1 51 | begin 52 | yield 53 | ensure 54 | apply if state.nesting == 1 55 | end 56 | ensure 57 | # Decrement nesting even if `apply` raised an error. 58 | state.nesting -= 1 59 | end 60 | 61 | # Apply the touches that were delayed. 62 | def self.apply 63 | begin 64 | ActiveRecord::Base.transaction do 65 | state.records_by_attrs_and_class.each do |attr, classes_and_records| 66 | classes_and_records.each do |klass, records| 67 | touch_records attr, klass, records 68 | end 69 | end 70 | end 71 | end while state.more_records? 72 | ensure 73 | state.clear_records 74 | end 75 | 76 | # Touch the specified records--non-empty set of instances of the same class. 77 | def self.touch_records(attr, klass, records) 78 | attributes = records.first.send(:timestamp_attributes_for_update_in_model) 79 | attributes << attr if attr 80 | 81 | if attributes.present? 82 | current_time = records.first.send(:current_time_from_proper_timezone) 83 | changes = {} 84 | 85 | attributes.each do |column| 86 | column = column.to_s 87 | changes[column] = current_time 88 | records.each do |record| 89 | # Don't bother if destroyed or not-saved 90 | next unless record.persisted? 91 | record.instance_eval do 92 | write_attribute column, current_time 93 | @changed_attributes.except!(*changes.keys) 94 | end 95 | end 96 | end 97 | 98 | klass.unscoped.where(klass.primary_key => records).update_all(changes) 99 | end 100 | state.updated attr, records 101 | records.each { |record| record.run_callbacks(:touch) } 102 | end 103 | end 104 | end 105 | 106 | ActiveRecord::Base.include ActiveRecord::DelayTouching 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Activerecord::DelayTouching 2 | 3 | > **Note:** this version requires ActiveRecord 4.2 or higher. To use ActiveRecord 3.2 through 4.1, use the branch https://github.com/godaddy/activerecord-delay_touching/tree/pre-activerecord-4.2. 4 | 5 | Batch up your ActiveRecord "touch" operations for better performance. 6 | 7 | When you want to invalidate a cache in Rails, you use `touch: true`. But when 8 | you modify a bunch of records that all `belong_to` the same owning record, that record 9 | will be touched N times. It's incredibly slow. 10 | 11 | With this gem, all `touch` operations are consolidated into as few database 12 | round-trips as possible. Instead of N touches you get 1 touch. 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | gem 'activerecord-delay_touching' 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself: 25 | 26 | $ gem install activerecord-delay_touching 27 | 28 | ## Usage 29 | 30 | The setup: 31 | 32 | class Person < ActiveRecord::Base 33 | has_many :pets 34 | accepts_nested_attributes_for :pets 35 | end 36 | 37 | class Pet < ActiveRecord::Base 38 | belongs_to :person, touch: true 39 | end 40 | 41 | Without `delay_touching`, this simple `update` in the controller calls 42 | `@person.touch` N times, where N is the number of pets that were updated 43 | via nested attributes. That's N-1 unnecessary round-trips to the database: 44 | 45 | class PeopleController < ApplicationController 46 | def update 47 | ... 48 | # 49 | @person.update(person_params) 50 | ... 51 | end 52 | end 53 | 54 | # SQL (0.1ms) UPDATE "people" SET "updated_at" = '2014-07-09 19:48:07.137158' WHERE "people"."id" = 1 55 | # SQL (0.1ms) UPDATE "people" SET "updated_at" = '2014-07-09 19:48:07.138457' WHERE "people"."id" = 1 56 | # SQL (0.1ms) UPDATE "people" SET "updated_at" = '2014-07-09 19:48:07.140088' WHERE "people"."id" = 1 57 | 58 | With `delay_touching`, @person is touched only once: 59 | 60 | ActiveRecord::Base.delay_touching do 61 | @person.update(person_params) 62 | end 63 | 64 | # SQL (0.1ms) UPDATE "people" SET "updated_at" = '2014-07-09 19:48:07.140088' WHERE "people"."id" = 1 65 | 66 | ## Consolidates Touches Per Table 67 | 68 | In the following example, a person gives his pet to another person. ActiveRecord 69 | automatically touches the old person and the new person. With `delay_touching`, 70 | this will only make a *single* round-trip to the database, setting `updated_at` 71 | for all Person records in a single SQL UPDATE statement. Not a big deal when there are 72 | only two touches, but when you're updating records en masse and have a cascade 73 | of hundreds touches, it really is a big deal. 74 | 75 | class Pet < ActiveRecord::Base 76 | belongs_to :person, touch: true 77 | 78 | def give(to_person) 79 | ActiveRecord::Base.delay_touching do 80 | self.person = to_person 81 | save! # touches old person and new person in a single SQL UPDATE. 82 | end 83 | end 84 | end 85 | 86 | ## Cascading Touches 87 | 88 | When `delay_touch` runs through and touches everything, it captures additional 89 | `touch` calls that might be called as side-effects. (E.g., in `after_touch` 90 | handlers.) Then it makes a second pass, batching up those touches as well. 91 | 92 | It keeps doing this until there are no more touches, or until the sun swallows 93 | up the earth. Whichever comes first. 94 | 95 | ## Gotchas 96 | 97 | Things to note: 98 | 99 | * `after_touch` callbacks are still fired for every instance, but not until the block is exited. 100 | And they won't happen in the same order as they would if you weren't batching up your touches. 101 | * If you call person1.touch and then person2.touch, and they are two separate instances 102 | with the same id, only person1's `after_touch` handler will be called. 103 | 104 | ## Contributing 105 | 106 | 1. Fork it ( https://github.com/godaddy/activerecord-delay_touching/fork ) 107 | 2. Create your feature branch (`git checkout -b my-new-feature`) 108 | 3. Commit your changes (`git commit -am 'Add some feature'`) 109 | 4. Push to the branch (`git push origin my-new-feature`) 110 | 5. Create a new Pull Request 111 | -------------------------------------------------------------------------------- /spec/activerecord/delay_touching_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Activerecord::DelayTouching do 4 | let(:person) { Person.create name: "Rosey" } 5 | let(:pet1) { Pet.create(name: "Bones") } 6 | let(:pet2) { Pet.create(name: "Ema") } 7 | 8 | it 'has a version number' do 9 | expect(Activerecord::DelayTouching::VERSION).not_to be nil 10 | end 11 | 12 | it 'touch returns true' do 13 | ActiveRecord::Base.delay_touching do 14 | expect(person.touch).to eq(true) 15 | end 16 | end 17 | 18 | it 'consolidates touches on a single record' do 19 | expect_updates ["people"] do 20 | ActiveRecord::Base.delay_touching do 21 | person.touch 22 | person.touch 23 | end 24 | end 25 | end 26 | 27 | it 'sets updated_at on the in-memory instance when it eventually touches the record' do 28 | original_time = new_time = nil 29 | 30 | Timecop.freeze(2014, 7, 4, 12, 0, 0) do 31 | original_time = Time.current 32 | person.touch 33 | end 34 | 35 | Timecop.freeze(2014, 7, 10, 12, 0, 0) do 36 | new_time = Time.current 37 | ActiveRecord::Base.delay_touching do 38 | person.touch 39 | expect(person.updated_at).to eq(original_time) 40 | expect(person.changed?).to be_falsey 41 | end 42 | end 43 | 44 | expect(person.updated_at).to eq(new_time) 45 | expect(person.changed?).to be_falsey 46 | end 47 | 48 | it 'does not mark the instance as changed when touch is called' do 49 | ActiveRecord::Base.delay_touching do 50 | person.touch 51 | expect(person).not_to be_changed 52 | end 53 | end 54 | 55 | it 'consolidates touches for all instances in a single table' do 56 | expect_updates ["pets"] do 57 | ActiveRecord::Base.delay_touching do 58 | pet1.touch 59 | pet2.touch 60 | end 61 | end 62 | end 63 | 64 | it 'does nothing if no_touching is on' do 65 | if ActiveRecord::Base.respond_to?(:no_touching) 66 | expect_updates [] do 67 | ActiveRecord::Base.no_touching do 68 | ActiveRecord::Base.delay_touching do 69 | person.touch 70 | end 71 | end 72 | end 73 | end 74 | end 75 | 76 | it 'only applies touches for which no_touching is off' do 77 | if Person.respond_to?(:no_touching) 78 | expect_updates ["pets"] do 79 | Person.no_touching do 80 | ActiveRecord::Base.delay_touching do 81 | person.touch 82 | pet1.touch 83 | end 84 | end 85 | end 86 | end 87 | end 88 | 89 | it 'does not apply nested touches if no_touching was turned on inside delay_touching' do 90 | if ActiveRecord::Base.respond_to?(:no_touching) 91 | expect_updates [ "people" ] do 92 | ActiveRecord::Base.delay_touching do 93 | person.touch 94 | ActiveRecord::Base.no_touching do 95 | pet1.touch 96 | end 97 | end 98 | end 99 | end 100 | end 101 | 102 | it 'can update nonstandard columns' do 103 | expect_updates [ "pets" => [ "updated_at", "neutered_at" ] ] do 104 | ActiveRecord::Base.delay_touching do 105 | pet1.touch :neutered_at 106 | end 107 | end 108 | end 109 | 110 | it 'splits up nonstandard column touches and standard column touches' do 111 | expect_updates [ { "pets" => [ "updated_at", "neutered_at" ] }, { "pets" => [ "updated_at" ] } ] do 112 | ActiveRecord::Base.delay_touching do 113 | pet1.touch :neutered_at 114 | pet2.touch 115 | end 116 | end 117 | end 118 | 119 | it 'can update multiple nonstandard columns of a single record in different calls to touch' do 120 | expect_updates [ { "pets" => [ "updated_at", "neutered_at" ] }, { "pets" => [ "updated_at", "fed_at" ] } ] do 121 | ActiveRecord::Base.delay_touching do 122 | pet1.touch :neutered_at 123 | pet1.touch :fed_at 124 | end 125 | end 126 | end 127 | 128 | context 'touch: true' do 129 | before do 130 | person.pets << pet1 131 | person.pets << pet2 132 | end 133 | 134 | it 'consolidates touch: true touches' do 135 | expect_updates [ "pets", "people" ] do 136 | ActiveRecord::Base.delay_touching do 137 | pet1.touch 138 | pet2.touch 139 | end 140 | end 141 | end 142 | 143 | it 'does not touch the owning record via touch: true if it was already touched explicitly' do 144 | expect_updates [ "pets", "people" ] do 145 | ActiveRecord::Base.delay_touching do 146 | person.touch 147 | pet1.touch 148 | pet2.touch 149 | end 150 | end 151 | end 152 | end 153 | 154 | context 'dependent deletes' do 155 | 156 | let(:post) { Post.create! } 157 | let(:user) { User.create! } 158 | let(:comment) { Comment.create! } 159 | 160 | before do 161 | post.comments << comment 162 | user.comments << comment 163 | end 164 | 165 | it 'does not attempt to touch deleted records' do 166 | expect do 167 | ActiveRecord::Base.delay_touching do 168 | post.destroy 169 | end 170 | end.not_to raise_error 171 | expect(post.destroyed?).to eq true 172 | end 173 | 174 | end 175 | 176 | context 'persistence fails and rolls back transaction' do 177 | 178 | it 'does not infinitely loop' do 179 | updates = 0 180 | allow(ActiveRecord::Base.connection).to receive(:update).and_wrap_original do |m, *args| 181 | updates = updates + 1 182 | raise StandardError, 'Too many updates - likely infinite loop detected' if updates > 1 183 | 184 | m.call(*args) 185 | end 186 | 187 | ActiveRecord::Base.delay_touching do 188 | ActiveRecord::Base.transaction do 189 | # write and touch any new record 190 | record = Post.create! 191 | record.touch 192 | raise ActiveRecord::Rollback 193 | end 194 | end 195 | 196 | end 197 | 198 | end 199 | 200 | def expect_updates(tables) 201 | expected_sql = tables.map do |entry| 202 | if entry.kind_of?(Hash) 203 | entry.map do |table, columns| 204 | Regexp.new(%Q{UPDATE "#{table}" SET #{columns.map { |column| %Q{"#{column}" =.+} }.join(", ") } }) 205 | end 206 | else 207 | Regexp.new(%Q{UPDATE "#{entry}" SET "updated_at" = }) 208 | end 209 | end.flatten 210 | expect(ActiveRecord::Base.connection).to receive(:update).exactly(expected_sql.length).times do |stmt, _, _| 211 | index = expected_sql.index { |sql| stmt.to_sql =~ sql} 212 | expect(index).to be, "An unexpected touch occurred: #{stmt.to_sql}" 213 | expected_sql.delete_at(index) 214 | end 215 | 216 | yield 217 | 218 | expect(expected_sql).to be_empty, "Some of the expected updates were not executed." 219 | end 220 | end 221 | --------------------------------------------------------------------------------