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.
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 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).
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,
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?
Instead, to better illustrate this exception to ActiveRecord’s convention, I might do:
Craig, passing parameters to
savedoesn’t work for me.
How do you use the last case in a real scenario?
Say that I want to increment a field called
viewsin Article class.
Also, for the thread safe version, it should really be:
For the version with
yield, you would pass a block: