Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Comparing changes

Choose two branches to see what's changed or to start a new pull request. If you need to, you can also compare across forks.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also compare across forks.
...
Checking mergeability… Don't worry, you can still create the pull request.
  • 13 commits
  • 11 files changed
  • 0 commit comments
  • 3 contributors
View
64 README.markdown
@@ -0,0 +1,64 @@
+Are you paranoid?
+=================
+
+Destroying records is a one-way ticket--you are permanently sending data
+down the drain. *Unless*, of course, you are using this plugin.
+
+Simply declare models paranoid:
+
+ class User < ActiveRecord::Base
+ is_paranoid
+ end
+
+You will need to add the "deleted_at" datetime column on each model table
+you declare paranoid. This is how the plugin tracks destroyed state.
+
+
+Destroying
+----------
+
+Calling `destroy` should work as you expect, only it doesn't actually delete the record:
+
+ User.count #=> 1
+
+ User.first.destroy
+
+ User.count #=> 0
+
+ # user is still there, only hidden:
+ User.count_with_destroyed #=> 1
+
+What `destroy` does is that it sets the "deleted\_at" column to the current time.
+Records that have a value for "deleted\_at" are considered deleted and are filtered
+out from all requests using `default_scope` ActiveRecord feature:
+
+ default_scope :conditions => {:deleted_at => nil}
+
+Restoring
+---------
+
+No sense in keeping the data if we can't restore it, right?
+
+ user = User.find_with_destroyed(:first)
+
+ user.restore
+
+ User.count #=> 1
+
+Restoring resets the "deleted_at" value back to `nil`.
+
+Extra methods
+-------------
+
+Extra class methods provided by this plugin are:
+
+1. `Model.count_with_destroyed(*args)`
+2. `Model.find_with_destroyed(*args)`
+2. `Model.find_only_destroyed(*args)`
+
+
+Pitfalls
+--------
+
+* `validates_uniqueness_of` does not ignore items marked with a "deleted_at" flag
+* various eager-loading and associations-related issues (see ["Killing is_paranoid"](http://blog.semanticart.com/killing_is_paranoid/))
View
76 README.textile
@@ -1,76 +0,0 @@
-h1. is_paranoid ( same as it ever was )
-
-h3. and you may ask yourself, well, how did I get here?
-
-Sometimes you want to delete something in ActiveRecord, but you realize you might need it later (for an undo feature, or just as a safety net, etc.). There are a plethora of plugins that accomplish this, the most famous of which is the venerable acts_as_paranoid which is great but not really actively developed any more. What's more, acts_as_paranoid was written for an older version of ActiveRecord and, with default_scope in 2.3, it is now possible to do the same thing with significantly less complexity. Thus, *is_paranoid*.
-
-h3. and you may ask yourself, how do I work this?
-
-You should read the specs, or the RDOC, or even the source itself (which is very readable), but for the lazy, here's the hand-holding:
-
-You need ActiveRecord 2.3 and you need to properly install this gem. Then you need a model with a deleted_at timestamp column on its database table. If that column is null, the item isn't deleted. If it has a timestamp, it should count as deleted.
-
-So let's assume we have a model Automobile that has a deleted_at column on the automobiles table.
-
-If you're working with Rails, in your environment.rb, add the following to your initializer block.
-
-<pre>
-Rails::Initializer.run do |config|
- # ...
- config.gem "jchupp-is_paranoid", :lib => 'is_paranoid', :version => ">= 0.0.1"
-end
-</pre>
-
-Then in your ActiveRecord model
-
-<pre>
-class Automobile < ActiveRecord::Base
- is_paranoid
-end
-</pre>
-
-Now our automobiles are now soft-deleteable.
-
-<pre>
- that_large_automobile = Automobile.create()
- Automobile.count # => 1
-
- that_large_automobile.destroy
- Automobile.count # => 0
- Automobile.count_with_destroyed # => 1
-
- # where is that large automobile?
- that_large_automobile = Automobile.find_with_destroyed(:all).first
- that_large_automobile.restore
- Automobile.count # => 1
-</pre>
-
-One thing to note, destroying is always undo-able, but deleting is not.
-
-<pre>
- Automobile.destroy_all
- Automobile.count # => 0
- Automobile.count_with_destroyed # => 1
-
- Automobile.delete_all
- Automobile.count_with_destroyed # => 0
- # And you may say to yourself, "My god! What have I done?"
-</pre>
-
-h3. Note:
-
- validates_uniqueness_of does not ignore items marked with a deleted_at flag. This is a behavior difference between is_paranoid and acts_as_paranoid. I'm going to treat this as a bug until I get a chance to make it an optional feature. Be aware of it.
-
-h3. and you may ask yourself, where does that highway go to?
-
-If you find any bugs, have any ideas of features you think are missing, or find things you're like to see work differently, feel free to send me a message or a pull request.
-
-Currently on the todo list:
-* deal with validates_uniqueness_of issue
-* add options for merging additional default_scope options (i.e. order, etc.)
-
-h3. Thanks
-
-Thanks to Rick Olson for acts_as_paranoid which is obviously an inspiration in concept and execution, Ryan Bates for mentioning the idea of using default_scope for this on Ryan Daigle's "post introducing default_scope":defscope, and the Talking Heads for being the Talking Heads.
-
-[defscope]http://ryandaigle.com/articles/2008/11/18/what-s-new-in-edge-rails-default-scoping
View
16 Rakefile
@@ -1,14 +1,12 @@
-require "spec"
-require "spec/rake/spectask"
-require 'lib/is_paranoid.rb'
+require 'spec/rake/spectask'
Spec::Rake::SpecTask.new do |t|
- t.spec_opts = ['--options', "\"#{File.dirname(__FILE__)}/spec/spec.opts\""]
+ t.ruby_opts = ['-rubygems']
+ t.libs = ['lib', 'spec']
+ t.spec_opts = ['--color']
t.spec_files = FileList['spec/**/*_spec.rb']
end
-task :install do
- rm_rf "*.gem"
- puts `gem build is_paranoid.gemspec`
- puts `sudo gem install is_paranoid-0.0.1.gem`
-end
+task :gem do
+ system %(rm -f *.gem; gem build is_paranoid.gemspec)
+end
View
1  init.rb
@@ -0,0 +1 @@
+require 'is_paranoid'
View
6 is_paranoid.gemspec
@@ -9,15 +9,13 @@ Gem::Specification.new do |s|
s.date = %q{2009-03-20}
s.email = %q{jeff@semanticart.com}
s.files = [
+ "init.rb",
"lib/is_paranoid.rb",
"README.textile",
"Rakefile",
"MIT-LICENSE",
- "spec/android_spec.rb",
- "spec/database.yml",
- "spec/spec.opts",
+ "spec/is_paranoid_spec.rb",
"spec/spec_helper.rb",
- "spec/schema.rb"
]
s.has_rdoc = true
s.homepage = %q{http://github.com/jchupp/is_paranoid/}
View
92 lib/is_paranoid.rb
@@ -1,4 +1,4 @@
-require 'activerecord'
+require 'active_record'
module IsParanoid
def self.included(base) # :nodoc:
@@ -15,10 +15,10 @@ module SafetyNet
# end
def is_paranoid
class_eval do
- # This is the real magic. All calls made to this model will append
- # the conditions deleted_at => nil. Exceptions require using
- # exclusive_scope (see self.delete_all, self.count_with_destroyed,
- # and self.find_with_destroyed )
+ # This is the real magic. All calls made to this model will
+ # append the conditions deleted_at => nil. Exceptions require
+ # using with_destroyed_scope (see self.delete_all,
+ # self.count_with_destroyed, and self.find_with_destroyed )
default_scope :conditions => {:deleted_at => nil}
# Actually delete the model, bypassing the safety net. Because
@@ -26,25 +26,39 @@ def is_paranoid
# delete method in each instance, we don't need to specify those
# methods separately
def self.delete_all conditions = nil
- self.with_exclusive_scope do
- super conditions
- end
+ self.with_destroyed_scope { super conditions }
end
# Return a count that includes the soft-deleted models.
def self.count_with_destroyed *args
- self.with_exclusive_scope { count(*args) }
+ self.with_destroyed_scope { count(*args) }
+ end
+
+ # Perform a count only on destroyed instances.
+ def self.count_only_destroyed *args
+ self.with_only_destroyed_scope { count(*args) }
end
# Return instances of all models matching the query regardless
# of whether or not they have been soft-deleted.
def self.find_with_destroyed *args
- self.with_exclusive_scope { find(*args) }
+ self.with_destroyed_scope { find(*args) }
+ end
+
+ # Perform a find only on destroyed instances.
+ def self.find_only_destroyed *args
+ self.with_only_destroyed_scope { find(*args) }
end
- # Mark the model deleted_at as now.
- def destroy_without_callbacks
- self.update_attribute(:deleted_at, Time.now.utc)
+ # Returns true if the requested record exists, even if it has
+ # been soft-deleted.
+ def self.exists_with_destroyed? *args
+ self.with_destroyed_scope { exists?(*args) }
+ end
+
+ # Returns true if the requested record has been soft-deleted.
+ def self.exists_only_destroyed? *args
+ self.with_only_destroyed_scope { exists?(*args) }
end
# Override the default destroy to allow us to flag deleted_at.
@@ -62,7 +76,57 @@ def destroy
# Set deleted_at flag on a model to nil, effectively undoing the
# soft-deletion.
def restore
- self.update_attribute(:deleted_at, nil)
+ self.deleted_at_will_change!
+ self.deleted_at = nil
+ update_without_callbacks
+ end
+
+ # Has this model been soft-deleted?
+ def destroyed?
+ super || !deleted_at.nil?
+ end
+
+ protected
+
+ # Mark the model deleted_at as now.
+ def destroy_without_callbacks
+ self.deleted_at = current_time_from_proper_timezone
+ update_without_callbacks
+ end
+
+ def self.with_only_destroyed_scope(&block)
+ with_destroyed_scope do
+ table = connection.quote_table_name(table_name)
+ attr = connection.quote_column_name(:deleted_at)
+ with_scope(:find => { :conditions => "#{table}.#{attr} IS NOT NULL" }, &block)
+ end
+ end
+
+ def self.with_destroyed_scope
+ find = current_scoped_methods[:find]
+
+ if find[:conditions]
+ original = find[:conditions].dup
+
+ begin
+ case find[:conditions]
+ when Hash:
+ if find[:conditions][:deleted_at].nil?
+ find[:conditions].delete(:deleted_at)
+ end
+ when String:
+ conditions = sanitize_conditions(:deleted_at => nil)
+ find[:conditions].gsub!(conditions, '1=1')
+ end
+
+ result = yield
+ ensure
+ find[:conditions] = original
+ return result if result
+ end
+ else
+ yield
+ end
end
end
end
View
3  spec/database.yml
@@ -1,3 +0,0 @@
-test:
- :adapter: sqlite3
- :dbfile: is_paranoid.db
View
32 spec/android_spec.rb → spec/is_paranoid_spec.rb
@@ -1,4 +1,4 @@
-require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
+require 'spec_helper'
class Person < ActiveRecord::Base
has_many :androids, :foreign_key => :owner_id, :dependent => :destroy
@@ -7,12 +7,15 @@ class Person < ActiveRecord::Base
class Android < ActiveRecord::Base
validates_uniqueness_of :name
is_paranoid
+ named_scope :ordered, :order => 'name DESC'
+ named_scope :r2d2, :conditions => { :name => 'R2D2' }
+ named_scope :c3p0, :conditions => { :name => 'C3P0' }
end
describe Android do
before(:each) do
- Android.delete_all
- Person.delete_all
+ Android.connection.execute 'DELETE FROM androids'
+ Person.connection.execute 'DELETE FROM people'
@luke = Person.create(:name => 'Luke Skywalker')
@r2d2 = Android.create(:name => 'R2D2', :owner_id => @luke.id)
@@ -35,7 +38,7 @@ class Android < ActiveRecord::Base
it "should handle Model.destroy(id) properly" do
lambda{
Android.destroy(@r2d2.id)
- }.should change(Android, :count).from(2).to(1)
+ }.should change(Android, :count).by(-1)
Android.count_with_destroyed.should == 2
end
@@ -64,7 +67,7 @@ class Android < ActiveRecord::Base
it "should mark deleted on dependent destroys" do
lambda{
@luke.destroy
- }.should change(Android, :count).from(2).to(0)
+ }.should change(Android, :count).by(-2)
Android.count_with_destroyed.should == 2
end
@@ -72,7 +75,7 @@ class Android < ActiveRecord::Base
@r2d2.destroy
lambda{
@r2d2.restore
- }.should change(Android, :count).from(1).to(2)
+ }.should change(Android, :count).by(1)
end
# Note: this isn't necessarily ideal, this just serves to demostrate
@@ -83,4 +86,19 @@ class Android < ActiveRecord::Base
Android.create!(:name => 'R2D2')
}.should raise_error(ActiveRecord::RecordInvalid)
end
-end
+
+ it "should find only destroyed videos" do
+ @r2d2.destroy
+ Android.find_only_destroyed(:all).should == [@r2d2]
+ end
+
+ it "should honor named scopes" do
+ @r2d2.destroy
+ @c3p0.destroy
+ Android.r2d2.find_only_destroyed(:all).should == [@r2d2]
+ Android.c3p0.ordered.find_only_destroyed(:all).should == [@c3p0]
+ Android.ordered.find_only_destroyed(:all).should == [@r2d2,@c3p0]
+ Android.r2d2.c3p0.find_only_destroyed(:all).should == []
+ Android.find_only_destroyed(:all).should == [@r2d2,@c3p0]
+ end
+end
View
15 spec/schema.rb
@@ -1,15 +0,0 @@
-ActiveRecord::Schema.define(:version => 20090317164830) do
- create_table "androids", :force => true do |t|
- t.string "name"
- t.integer "owner_id"
- t.datetime "deleted_at"
- t.datetime "created_at"
- t.datetime "updated_at"
- end
-
- create_table "people", :force => true do |t|
- t.string "name"
- t.datetime "created_at"
- t.datetime "updated_at"
- end
-end
View
1  spec/spec.opts
@@ -1 +0,0 @@
---color
View
36 spec/spec_helper.rb
@@ -1,14 +1,28 @@
-require 'rubygems'
-require "#{File.dirname(__FILE__)}/../lib/is_paranoid"
-require 'activerecord'
require 'yaml'
-require 'spec'
+require 'active_record'
+require 'is_paranoid'
+require 'stringio'
-def connect(environment)
- conf = YAML::load(File.open(File.dirname(__FILE__) + '/database.yml'))
- ActiveRecord::Base.establish_connection(conf[environment])
-end
+# ActiveRecord::Base.logger = Logger.new(STDOUT)
+ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
+
+old_stdout = $stdout
+$stdout = StringIO.new
-# Open ActiveRecord connection
-connect('test')
-load(File.dirname(__FILE__) + "/schema.rb")
+begin
+ ActiveRecord::Schema.define do
+ create_table :androids do |t|
+ t.string :name
+ t.integer :owner_id
+ t.datetime :deleted_at
+ t.timestamps
+ end
+
+ create_table :people do |t|
+ t.string :name
+ t.timestamps
+ end
+ end
+ensure
+ $stdout = old_stdout
+end

No commit comments for this range

Something went wrong with that request. Please try again.