Tuesday, October 5, 2010

Using acts_as_archive instead of soft delete

For the application I am working on right now, the ability to restore content that has been deleted is one of the requirements. A lot of people would just go ahead and add acts_as_paranoid or is_paranoid and be done with it, but I've had trouble with that approach before.
I've been reading a lot about the trouble with "soft deletes" (flagging a record as deleted instead of deleting it). Using a plugin that monkey patches ActiveRecord can go a long way towards fixing thesee problems, but it's a leaky abstraction and will bite you in the ass in unexpected ways. For example, all your uniqueness validations (and indexes) become much more complicated.
That's why Jeffrey Chupp decided to kill is_paranoid and Rick Olson doesn't use acts_as_paranoid any more.
There are other problems too. If you delete a lot of records, and you keep them in the same table, your table can get quite large, and all your queries slow down. At this point you have to use partitioning or partial indexes to get acceptable performance.

Alternatives to soft delete

In my reading, I found two alternatives to soft delete to be compelling.
The first was the suggestion to properly model your domain. Why do you want to delete a record? What does that mean? Udi Dahan puts it this way:
Orders aren’t deleted – they’re cancelled. There may also be fees incurred if the order is canceled too late.
Employees aren’t deleted – they’re fired (or possibly retired). A compensation package often needs to be handled.
Jobs aren’t deleted – they’re filled (or their requisition is revoked).
Keeping that in mind, what if the task at hand really is to delete the record? The other idea that I liked was to archive the records in another table.
The first Rails plugin I came across that implemented this was acts_as_soft_deletable which besides being misnamed doesn't appear to be actively maintained. The author even disavows the plugin somewhat for Rails 2.3:
Before using this with a new Rails 2.3 app, you may want to consider using the new default_scope feature (or named_scopes) with a deleted_at flag.
Then I found acts_as_archive which is more recently maintained and used in production for a major Rails website.
There was only one problem -- acts_as_archive didn't support PostgreSQL. Fortunately, that was easy enough to fix.

Restoring deleted records with acts_as_archive

acts_as_archive has the ability to restore a deleted record, but only that record, not associated records.
I was troubled by this at first, but after thinking about it I came to the conclusion that restoring a network of objects is an application-dependant problem. Here's one way to achieve it.
Imagine you have a model like this, with Posts having many Comments and Votes.
Post model
A Post can be deleted, and when it is, it should take the Comments and Votes with it:
class Post
  acts_as_archive

  has_many :votes, :dependent => :destroy
  has_many :comments, :dependent => :destroy
end
(Assume Comment and Vote also have acts_as_archive.)
Now, I can restore a Post with its associated Votes and Comments like this:
def self.restore(id)
  transaction do
    Post.restore_all(["id = ?", id])
    post = Post.find(id)

    Vote.restore_all(Vote::Archive.all(:conditions => ["post_id = ?", id]).map(&:id))
    Comment.restore_all(Comment::Archive.all(:conditions => ["post_id = ?", id]).map(&:id))
  end
In my real code, I've broken apart the two pieces of this into a class method restore and an instance method post_restore which the freshly restored object uses to find its associated records and restore them. post_restore also takes care of post-restore tasks like putting the object back in the Solr index.
This all works great. But now let's say Comments can be deleted individually, and we want to restore them.
Here the logic is a little different, because a Comment can't be restored unless its parent Post still exists (unless it's being restored by the Post, as above).
I take care of this logic in the administrative controller, by only showing child objects that it's valid to restore, and my foreign key constraints prevent anyone from getting around that.

I really wanted to delete that!

Sometimes you don't want to archive a deleted object. For example, in the application I'm working on, votes are canceled by re-voting. I don't want to save those votes -- there's no point, and it can even cause problems with restoring. Imagine having several archived votes from a user for a Post, and then deleting and restoring that Post. The restoration will try to bring back all the votes. Again, I catch this with a uniqueness constraint, but I don't want it to happen in the first place.
Fortunately acts_as_archive has me covered.
To destroy a record without archiving it, you can use destroy!. Likewise for deleting, there is delete_all!.

http://railspikes.com/2010/2/26/acts-as-archive?utm_source=feedburner&utm_medium=feed&utm_campaign=Feed%3A+RailSpikes+%28Rail+Spikes%29

No comments: