Rails 3 Scopes: With great power comes great responsibility.

Posted on January 29, 2011 in rails, fail

I wrote one of the worst bugs of my career (so far) the other day.

The long and short of it is this:
ActiveRecord::Relation may behave like an array in many ways, but it’s not an array. If you are doing something which is completely harmless to an array, but could be potentially destructive to an active record relation, make sure what you have really is an array.

It goes like this

Create a fresh rails3 project, and set it up to use rspec.

$ rails new todo -T
# Gemfile
source 'http://rubygems.org'
gem 'rails', '~> 3.0'
gem 'sqlite3-ruby', :require => 'sqlite3'
gem 'rspec-rails', '~> 2.4'
rails g rspec:install

Create a simple model.

rails g migration create_tasks
class CreateTasks < ActiveRecord::Migration
  def self.up
    create_table :tasks do |t|
      t.column :description, :text
      t.column :done, :boolean, :default => false
      t.timestamps
    end
  end
 def self.down
    drop_table :tasks
  end
end
rake db:migrate && rake db:test:prepare

Add a scope

class Task < ActiveRecord::Base
  scope :done, where('done = ?', true)
end

This is where it gets interesting.

require 'spec_helper'
describe Task do
  it "cleanly extracts from array" do
    trucs = {:des => :trucs}
    machin = {:un => :machin}
    bidule = {:une => :bidule}
    stuff = [trucs, machin, bidule]
    extracted = stuff.delete(machin)
    extracted.should == machin
    stuff.should_not include(machin)
  end
  context "active record relation" do
    it "extracts cleanly" do
      jump = Task.create(:description => 'jump')
      run = Task.create(:description => 'run')
      walk = Task.create(:description => 'walk')
      activities = Task.all # activities.class == Array
      extracted = activities.delete(run)
      extracted.should == run
      activities.should_not include(run)
      Task.all.should include(run)
    end
    it "doesn't. Sneaky bastard" do
      jump = Task.create(:description => 'jump', :done => true)
      run = Task.create(:description => 'run', :done => true)
      walk = Task.create(:description => 'walk', :done => true)
      activities = Task.done # activities.class == ActiveRecord::Relation
      extracted = activities.delete(run)
      Task.all.should include(run)
      # fails
      # incidentally, also fails on these:
      # popped.should == run
      # activities.should_not include(run)
     end
  end
end

To quote the log:

AREL (0.2ms) DELETE FROM “tasks” WHERE (done = ‘t’) AND (“tasks”.“id” = 2)

Yeah. Potentially really not good.

0 comments

Post a comment


(lesstile enabled - surround code blocks with ---)