Using default_scope to recreate acts_as_paranoid

Intro

Rails 2.3 gives us “default_scope” which was described by Ryan Daigle as allowing you to “specify default ordering, and other scopes, in edge rails directly in your ActiveRecord model.” Ryan’s post gives some good examples of when you might want to use this (specifically on models where you always want them sorted in a specific manner). In the comments of that post, Ryan Bates suggests that this might be useful for “simulating destroying a model (like acts_as_paranoid).” Indeed this is possible and the idea of using scoping to create this is both present in acts_as_paranoid itself and also has been brought up before.

In this article I’m going to reinvent the wheel using default_scope to illustrate how powerful default_scope is and how trivial it makes this task. You can keep track of the finished product in my is_paranoid gem.

Getting started

The syntax for declaring a default_scope is simple. Here’s what Ryan D. uses in his article:

class Article < ActiveRecord::Base

  default_scope :order => 'created_at DESC'

end

To break this down, every time we use ActiveRecord to query our database, it will add the “created_at DESC” order to our query. Here’s a sidebar word of warning about default_scope: That’s every query, so you’re incurring some unnecessary overhead if you haphazardly set default scopes when you don’t really need them. As a trivial example, the show action on our articles controller doesn’t need to sort the articles by created_at since it should only be finding one article anyway.

If you’re unfamiliar with acts_as_paranoid, it allows “you to hide and restore records without actually deleting them.” The plugin uses a timestamp field on your table to specify whether an item should count as deleted or not; if the deleted_at timestamp is nil, it is not deleted, if the timestamp is not nil, it is deleted. The migration for articles with our deleted_at column looks like this:

class CreateArticles < ActiveRecord::Migration

  def self.up

    create_table :articles do |t|

      t.string :name

      t.text :body

      t.timestamp :deleted_at, :default => nil

      t.timestamps

    end

  end



  def self.down

    drop_table :articles

  end

end

And our default scope should look like:

class Article < ActiveRecord::Base  

  default_scope :conditions => {:deleted_at => nil}

end

Now in script/console if you do Article.first, you can see the effects in your log: "SELECT * FROM “articles” WHERE (“articles”.“deleted_at” IS NULL) LIMIT 1"

We’ll need to redefine destroy if we want it to mark an item deleted instead of actually deleting it.

class Article < ActiveRecord::Base  

  default_scope :conditions => {:deleted_at => nil}



  def destroy

    self.update_attribute(:deleted_at, Time.now.utc)

  end

end

Let’s give it a spin:

>> a = Article.create(:name => "Test Article", :body => "some body")

=> #<Article id: 3, name: "Test Article", body: "some body", deleted_at: nil, created_at: "2009-03-22 16:08:00", updated_at: "2009-03-22 16:08:00">

>> a.destroy

=> true

>> Article.first

=> nil

But the log shows it actually just did "UPDATE “articles” SET “updated_at” = ‘2009-03-22 16:08:02’, “deleted_at” = ‘2009-03-22 16:08:02’ WHERE “id” = 3" so our article is still there, we simply can’t find it because of our default_scope.

Sadly there’s something we’ve broken already, though. Because we redefined destroy, we can’t use our destroy callbacks, like before_destroy, anymore. We can fix that by changing the way we implement destroy. You should check out lib/is_paranoid.rb in the repo to see how this is implemented. Let’s move on.

Now we need to add a method to help us find things that have been marked deleted. To do that, we’ll need to bypass our default_scope by using with_exclusive_scope.

def self.find_with_destroyed *args

  self.with_exclusive_scope { find(*args) }

end

Essentially this takes whatever arguments you provide as finder conditions (and/or order, includes, etc.) and passes them to the find method after first specifying that we should ignore the default_scope. Now we can find our destroyed items as well as our non-destroyed items.

>> Article.find_with_destroyed(:first)

=> #<Article id: 3, name: "Test Article", body: "some body", deleted_at: "2009-03-22 16:08:02", created_at: "2009-03-22 16:08:00", updated_at: "2009-03-22 16:08:02">

There’s still a few features to add, like count_with_destroyed and a restore method, but this should give you a good intro to the power of default_scope. Both those features are added in the current version of is_paranoid and we’re still sitting on less than 40 actual lines of code to accomplish the core functionality of acts_as_paranoid.

Why you might not want to use default_scope

There’s the previously mentioned overhead incurred with applying order or conditions to every interaction ActiveRecord has with your database, but beyond that, some people in the community feel that overriding find methods can add unnecessary complexity to your code and make debugging more complicated. In fact, Rick Olson, the author of acts_as_paranoid, no longer uses AAP in favor of hidden and visible named scopes on his models.

Granted, it is more readily apparent that you’re in a named scope since they’re explicitly called, but if you have a good number of models that you want to be able to soft-delete or hide, then it seems that declaring those scopes on each model isn’t very DRY. I’d wager most people end up using a mixin to prevent repetition. I don’t feel that using default_scope in a manner such as this is far beyond simply DRYing things up.

Besides, anyone should be able to easily grok the code in is_paranoid, as brief and simple as it is. Ultimately it comes down to personal preference. As always, makes sure you know your toolset.

In praise of ActiveRecord (rat-hole)

As a quick rat-hole, ActiveRecord implements destroy and delete_all fantastically. Basically Model.destroy and Model.destroy_all both internally call instance.destroy and Model.delete and instance.delete both internally call Model.destroy_all. Because of this, you only have to redefine delete and destroy once for them to take effect across the other methods. Awesome.

In closing

If you’re looking for a light-weight acts_as_paranoid replacement and you’re on Rails 2.3+ then give is_paranoid a look. The readme should give a decent overview, but beyond that, check out the specs and read the source.

I want to keep is_paranoid light-weight, but if anything important is missing or if you uncover any bugs, please let me know.