Skip to content

Commit

Permalink
add #no_touching on ActiveRecord models
Browse files Browse the repository at this point in the history
  • Loading branch information
dmathieu committed Nov 13, 2013
1 parent bba8bb8 commit b32ba36
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 0 deletions.
10 changes: 10 additions & 0 deletions activerecord/CHANGELOG.md
@@ -1,3 +1,13 @@
* Added `ActiveRecord::Base.no_touching`, which allows ignoring touch on models.

Examples:

Post.no_touching do
Post.first.touch
end

*Sam Stephenson*, *Damien Mathieu*

* Prevent the counter cache from being decremented twice when destroying
a record on a has_many :through association.

Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record.rb
Expand Up @@ -45,6 +45,7 @@ module ActiveRecord
autoload :Migrator, 'active_record/migration'
autoload :ModelSchema
autoload :NestedAttributes
autoload :NoTouching
autoload :Persistence
autoload :QueryCache
autoload :Querying
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record/base.rb
Expand Up @@ -295,6 +295,7 @@ class Base
extend Delegation::DelegateCache

include Persistence
include NoTouching
include ReadonlyAttributes
include ModelSchema
include Inheritance
Expand Down
52 changes: 52 additions & 0 deletions activerecord/lib/active_record/no_touching.rb
@@ -0,0 +1,52 @@
module ActiveRecord
# = Active Record No Touching
module NoTouching
extend ActiveSupport::Concern

module ClassMethods
# Lets you selectively disable calls to `touch` for the
# duration of a block.
#
# ==== Examples
# ActiveRecord::Base.no_touching do
# Project.first.touch # does nothing
# Message.first.touch # does nothing
# end
#
# Project.no_touching do
# Project.first.touch # does nothing
# Message.first.touch # works, but does not touch the associated project
# end
#
def no_touching(&block)
NoTouching.apply_to(self, &block)
end
end

class << self
def apply_to(klass) #:nodoc:
klasses.push(klass)
yield
ensure
klasses.pop
end

def applied_to?(klass) #:nodoc:
klasses.any? { |k| k >= klass }
end

private
def klasses
Thread.current[:no_touching_classes] ||= []
end
end

def no_touching?
NoTouching.applied_to?(self.class)
end

def touch(*)
super unless no_touching?
end
end
end
48 changes: 48 additions & 0 deletions activerecord/test/cases/timestamp_test.rb
Expand Up @@ -11,6 +11,7 @@ class TimestampTest < ActiveRecord::TestCase

def setup
@developer = Developer.first
@owner = Owner.first
@developer.update_columns(updated_at: Time.now.prev_month)
@previously_updated_at = @developer.updated_at
end
Expand Down Expand Up @@ -92,6 +93,53 @@ def test_touching_a_record_without_timestamps_is_unexceptional
assert_nothing_raised { Car.first.touch }
end

def test_touching_a_no_touching_object
Developer.no_touching do
assert @developer.no_touching?
assert !@owner.no_touching?
@developer.touch
end

assert !@developer.no_touching?
assert !@owner.no_touching?
assert_equal @previously_updated_at, @developer.updated_at
end

def test_touching_related_objects
@owner = Owner.first
@previously_updated_at = @owner.updated_at

Owner.no_touching do
@owner.pets.first.touch
end

assert_equal @previously_updated_at, @owner.updated_at
end

def test_global_no_touching
ActiveRecord::Base.no_touching do
assert @developer.no_touching?
assert @owner.no_touching?
@developer.touch
end

assert !@developer.no_touching?
assert !@owner.no_touching?
assert_equal @previously_updated_at, @developer.updated_at
end

def test_no_touching_threadsafe
Thread.new do
Developer.no_touching do
assert @developer.no_touching?

sleep(1)
end
end

assert !@developer.no_touching?
end

def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_update_the_parent_updated_at
pet = Pet.first
owner = pet.owner
Expand Down

7 comments on commit b32ba36

@sikachu
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmathieu
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💚

@tomdale
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http://gifrific.com/wp-content/uploads/2013/02/No-Touching-George-Bluth-Arrested-Development.gif

@fphilipe
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmathieu Could you give an example of a use case for this? I guess it could come in handy, but I can't come up with an example from the top of my head. 😊

@dmathieu
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fphilipe: this was a feature request coming from @dhh. I don't have a personal use case for it, I just did the implementation.
He says, as a use case:

Useful when copying object graphs while preserving the original timestamps, for example.

@fphilipe
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmathieu OK, thanks!

@dhh
Copy link
Member

@dhh dhh commented on b32ba36 Nov 14, 2013

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are two use cases from Basecamp:

def create_taggings_from_mass_assignment
  if @tag_names
    # Normally, tagging a taggable touches the taggable to expire
    # its caches. We can skip the touches here since we're in an
    # after_create callback and we know there are no caches to
    # expire. (Skipping these touches also lets us preserve the
    # original timestamps when copying messages and uploads with
    # tagged attachments.)

    ActiveRecord::Base.no_touching do
      set_tag_names(@tag_names)
    end

    @tag_names = nil
  end
end
class Copier
  def initialize(source, account)
    @source, @account = source, account
  end

  def copy
    ActiveRecord::Base.no_touching do
      @creator = find_person || create_person
      @project = create_project

      copy_todolists
      copy_messages
      copy_documents

      @project.reset_counters
      @project
    end
  rescue
    @project.try(:destroy)
    raise
  ensure
    @creator.try(:trash)
  end

Please sign in to comment.