hacking activerecord’s automatic timestamps

ActiveRecord’s automatic timestamping is handy. But what if we want some attributes to be exempt? Here’s how.

why do we need this?

Say we have a full-text search client that periodically updates the index for every record. We want to record the last time a record was indexed, so we make an indexed_at field. But setting indexed_at shouldn’t change updated_at; that wouldn’t make sense. We don’t need to timestamp our timestamps.

if i only had a brain

I think we can take for granted that our feature belongs in the model, since it’s a type of attribute update. But if we are too cool for that, we could bash it into the controller.

class VegetableController < ApplicationController
  def read_for_index
    @obj = Vegetable.find(params[:id])
    Vegetable.record_timestamps = false
    @obj.update_attribute(:indexed_at, Time.now)
    Vegetable.record_timestamps = true
  end
end

Omg! Never do that. We are messing with the entire model class for the sake of a single controller action.

the brain-man cometh

In my opinion, the best way to implement this is to override only the accessor:

class Vegetable < ActiveRecord::Base
  def indexed_at= when
    self.class.record_timestamps = false
    self[:indexed_at] = when
    save!
    self.class.record_timestamps = true
  end
end

This is obvious and fast. However, this works reliably only because Rails is not-thread safe. If we had threaded Rails, there is the possibility for a race condition if two record updates are performed at the same time. An unrelated update that was supposed to stamp, but occurred exactly between the true/false flip, would not stamp correctly. This is true even if it’s in another class, since the @@record_timestamps setting belongs to ActiveRecord::Base.

thread-safe version

What if DHH shows up sloshed at our New Year’s party and gives us Threaded Rails as a present?

class Vegetable < ActiveRecord::Base
  def indexed_at= when
    class << self
      def record_timestamps; false; end
    end
    self[:indexed_at] = when
    save!
    self.remove_method :record_timestamps
  end
end

This is thread-safe because it changes the record_timestamps on the instance’s metaclass, rather than the instance’s real class. No other instance in any other thread or context will be affected.

a derailment about meta issues

What’s the deal with remove_method? It’s because instances with modified metaclasses can’t be marshalled. That means that we couldn’t store the record in the session after calling indexed_at=. We would have to reload it from the DB.

We can’t use undef_method either. undef_method overwrites the method with a method that always raises NoMethodError. Contrast this to remove_method, which removes the method entirely, restoring the original method inheritance chain. The “real” record_timestamps() is inherited from ActiveRecord::Base (which includes Timestamp).

How come record_timestamps() is an instance method to begin with, though? It’s because @@record_timestamps is defined with cattr_accessor, which adds accessors to the instances as well as to the class.

There are a couple of ridiculous workarounds to the class problem. First, we could define the class-level record_timestamps() of Vegetable, instead of ActiveRecord::Base, and limit the scope to at least only Vegetables. Second, we could override Rails’ update_with_timestamps method on the instance instead, and then remove_method that. Or, lastly and horribly, we could clone the parent class, change its record_timestamps(), and use evil.rb to temporarily reparent the instance for the duration of the save (I hope that last suggestion made you gag).

callback on me

Ok, forget about threads. If we set up our callbacks properly, we can let anyone update indexed_at any way they please (accessors, attributes hash, update_attribute, create):

class Vegetable < ActiveRecord::Base
  def before_save
    unless self.new_record?
      # skip timestamping if indexed_at is the only changed field
      self.record_timestamps = false if self.attributes ==
        self.class.find(self.id).attributes.merge(:indexed_at => indexed_at)
    end
  end

  def after_save
    # it's always safe to re-enable at this point
    self.record_timestamps = true
  end
end

This is kind of cool. Unfortunately it requires an extra trip to the database to see if only indexed_at is changed (because otherwise timestamps really do need to happen). We could even move it into ActiveRecord::Base if we wanted to, and support per-class configuration with subclass (or instance) constants.

agggh too much brains

What if we want any arbitrary action to be performed without timestamp updating? This is probably bad; it would be better to make regular fields to contain the data we are trying to conditionally shoehorn into the timestamp. Ignoring that:

class ActiveRecord::Base
  def skip_stamp
    self.record_timestamps = false
    yield
    self.record_timestamps = true
  end
end

Oh man. Too easy. If we wanted to be really ridiculous, we could create a record_timestamps field in the model itself, and override ActiveRecord to look at that per instance to determine whether to timestamp. We would need to default to ActiveRecord::Base#record_timestamps() in case some models don’t have such a field.

That, though, is a total deviation from the original use case. Before you ever implement anything, ask: what’s the use case?

4 responses

  1. Instead, to better illustrate this exception to ActiveRecord’s convention, I might do:

    def index!
      self[:indexed_at] = Time.now
      save(:updated_on => updated_on)
    end
    
  2. Craig, passing parameters to save doesn’t work for me.

    Loading development environment.
    >> irb Recipe.find_first
    >> updated_at
    => Sat Dec 30 10:06:45 EST 2006
    >> self[:date] = Time.now
    => Sat Dec 30 10:09:04 EST 2006
    >> save(:updated_at => updated_at)
    => true
    >> reload
    => #<Recipe:0x28bd928...>
    >> updated_at
    => Sat Dec 30 10:09:21 EST 2006
    >>
    
  3. How do you use the last case in a real scenario?

    class ActiveRecord::Base
      def skip_stamp
        self.record_timestamps = false
        yield
        self.record_timestamps = true
      end
    end
    

    Say that I want to increment a field called views in Article class.

    Also, for the thread safe version, it should really be:

    def increment_views
      class << self
        def record_timestamps; false; end
      end
    
      self.increment! :views
    
      class << self
        remove_method :record_timestamps
      end
    end
    
Follow

Get every new post delivered to your Inbox.

Join 514 other followers