Rails 3 Scopes: With great power comes great responsibility.
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.