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