Permalink
Browse files

Merge branch 'master' of git://github.com/jchupp/is_paranoid

Conflicts:
	lib/is_paranoid.rb
  • Loading branch information...
andhapp committed Apr 30, 2009
2 parents 74e8ba2 + 5a11d74 commit e1c7adb92257bf9e17a6e0f75ff23625c2fc0211
Showing with 437 additions and 175 deletions.
  1. +4 −0 .gitignore
  2. +20 −0 CHANGELOG
  3. +38 −3 README.textile
  4. +13 −5 Rakefile
  5. +4 −0 VERSION.yml
  6. +1 −0 init.rb
  7. +8 −19 is_paranoid.gemspec
  8. +124 −72 lib/is_paranoid.rb
  9. +0 −76 spec/android_spec.rb
  10. +207 −0 spec/is_paranoid_spec.rb
  11. +18 −0 spec/schema.rb
View
@@ -0,0 +1,4 @@
+is_paranoid.db
+pkg
+*.gem
+doc/
View
@@ -0,0 +1,20 @@
+This will only document major changes. Please see the commit log for minor changes.
+
+-2009-04-27
+* restoring models now cascades to child dependent => destroy models via Matt Todd
+
+-2009-04-22
+* destroying and restoring records no longer triggers saving/updating callbacks
+
+-2009-03-28
+* removing syntax for calculation require (all find and ActiveRecord calculations are done on-the-fly now via method_missing)
+* adding ability to specify alternate fields and values for destroyed objects
+* adding in support for _destroyed_only methods (with inspiration from David Krmpotic)
+* adding init.rb via David Krmpotic
+* adding jewler tasks via Scott Woods
+
+-2009-03-24
+* requiring specific syntax to include calculations
+
+-2009-03-21
+* initial release
View
@@ -8,11 +8,11 @@ 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.
+You need ActiveRecord 2.3 and you need to properly install this gem. Then you need a model with a field to serve as a flag column on its database table. For this example we'll use a timestamp named "deleted_at". 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.
+If you're working with Rails, in your environment.rb, add the following to your initializer block (you may want to change the version number).
<pre>
Rails::Initializer.run do |config|
@@ -45,7 +45,7 @@ Now our automobiles are now soft-deleteable.
Automobile.count # => 1
</pre>
-One thing to note, destroying is always undo-able, but deleting is not.
+One thing to note, destroying is always undo-able, but deleting is not. This is a behavior difference between acts_as_paranoid and is_paranoid.
<pre>
Automobile.destroy_all
@@ -57,10 +57,45 @@ One thing to note, destroying is always undo-able, but deleting is not.
# And you may say to yourself, "My god! What have I done?"
</pre>
+All calculations and finds are created via a define_method call in method_missing. So you don't get a bunch of unnecessary methods defined unless you use them. Any find/count/sum/etc. _with_destroyed calls should work and you can also do find/count/sum/etc._destroyed_only.
+
+h3. Specifying alternate rules for what should be considered destroyed
+
+"deleted_at" as a timestamp is what acts_as_paranoid uses to define what is and isn't destroyed (see above), but you can specify alternate options with is_paranoid. In the is_paranoid line of your model you can specify the field, the value the field should have if the entry should count as destroyed, and the value the field should have if the entry is not destroyed. Consider the following models:
+
+<pre>
+ class Pirate < ActiveRecord::Base
+ is_paranoid :field => [:alive, false, true]
+ end
+
+ class DeadPirate < ActiveRecord::Base
+ set_table_name :pirates
+ is_paranoid :field => [:alive, true, false]
+ end
+</pre>
+
+These two models share the same table, but when we are finding Pirates, we're only interested in those that are alive. To break it down, we specify :alive as our field to check, false as what the model field should be marked at when destroyed and true to what the field should be if they're not destroyed. DeadPirates are specified as the opposite. Check out the specs if you're still confused.
+
+h3. Note about validates_uniqueness_of:
+
+validates_uniqueness_of does not, by default, ignore items marked with a deleted_at (or other field name) flag. This is a behavior difference between is_paranoid and acts_as_paranoid. You can overcome this by specifying the field name you are using to mark destroyed items as your scope. Example:
+
+<pre>
+ class Android < ActiveRecord::Base
+ validates_uniqueness_of :name, :scope => :deleted_at
+ is_paranoid
+ end
+</pre>
+
+And now the validates_uniqueness_of will ignore items that are destroyed.
+
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:
+* 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.
View
@@ -7,8 +7,16 @@ Spec::Rake::SpecTask.new do |t|
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
+begin
+ require 'jeweler'
+ Jeweler::Tasks.new do |s|
+ s.name = %q{is_paranoid}
+ s.summary = %q{ActiveRecord 2.3 compatible gem "allowing you to hide and restore records without actually deleting them." Yes, like acts_as_paranoid, only with less code and less complexity.}
+ s.email = %q{jeff@semanticart.com}
+ s.homepage = %q{http://github.com/jchupp/is_paranoid/}
+ s.description = ""
+ s.authors = ["Jeffrey Chupp"]
+ end
+rescue LoadError
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
+end
View
@@ -0,0 +1,4 @@
+---
+:major: 0
+:minor: 7
+:patch: 0
View
@@ -0,0 +1 @@
+require 'is_paranoid'
View
@@ -2,39 +2,28 @@
Gem::Specification.new do |s|
s.name = %q{is_paranoid}
- s.version = "0.0.2"
+ s.version = "0.7.0"
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.authors = ["Jeffrey Chupp"]
- s.date = %q{2009-03-20}
+ s.date = %q{2009-04-27}
+ s.description = %q{}
s.email = %q{jeff@semanticart.com}
- s.files = [
- "lib/is_paranoid.rb",
- "README.textile",
- "Rakefile",
- "MIT-LICENSE",
- "spec/android_spec.rb",
- "spec/database.yml",
- "spec/spec.opts",
- "spec/spec_helper.rb",
- "spec/schema.rb"
- ]
+ s.files = ["README.textile", "VERSION.yml", "lib/is_paranoid.rb", "spec/database.yml", "spec/is_paranoid_spec.rb", "spec/schema.rb", "spec/spec.opts", "spec/spec_helper.rb"]
s.has_rdoc = true
s.homepage = %q{http://github.com/jchupp/is_paranoid/}
+ s.rdoc_options = ["--inline-source", "--charset=UTF-8"]
s.require_paths = ["lib"]
- s.rubygems_version = %q{1.3.1}
+ s.rubygems_version = %q{1.3.2}
s.summary = %q{ActiveRecord 2.3 compatible gem "allowing you to hide and restore records without actually deleting them." Yes, like acts_as_paranoid, only with less code and less complexity.}
if s.respond_to? :specification_version then
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
- s.specification_version = 2
+ s.specification_version = 3
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
- s.add_runtime_dependency(%q<activerecord>, [">=2.3.0"])
else
- s.add_dependency(%q<activerecord>, [">=2.3.0"])
end
else
- s.add_dependency(%q<activerecord>, [">=2.3.0"])
end
-end
+end
View
@@ -1,78 +1,130 @@
-require 'active_record'
-
+require 'activerecord'
+
module IsParanoid
- def self.included(base) # :nodoc:
- base.extend SafetyNet
+ # Call this in your model to enable all the safety-net goodness
+ #
+ # Example:
+ #
+ # class Android < ActiveRecord::Base
+ # is_paranoid
+ # end
+ #
+ def is_paranoid opts = {}
+ opts[:field] ||= [:deleted_at, Proc.new{Time.now.utc}, nil]
+ class_inheritable_accessor :destroyed_field, :field_destroyed, :field_not_destroyed
+ self.destroyed_field, self.field_destroyed, self.field_not_destroyed = opts[:field]
+
+ include ClassAndInstanceMethods
end
-
- module SafetyNet
- # Call this in your model to enable all the safety-net goodness
- #
- # Example:
- #
- # class Android < ActiveRecord::Base
- # is_paranoid
- # end
- def is_paranoid
- # 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 )
- default_scope :conditions => {:deleted_at => nil}
-
- extend ClassMethods
- include InstanceMethods
- end
- end
-
- module ClassMethods
- # Actually delete the model, bypassing the safety net. Because
- # this method is called internally by Model.delete(id) and on the
- # delete method in each instance, we don't need to specify those
- # methods separately
- def delete_all conditions = nil
- self.with_exclusive_scope do
- super conditions
+
+ module ClassAndInstanceMethods
+ def self.included(base)
+ base.class_eval do
+ # This is the real magic. All calls made to this model will append
+ # the conditions deleted_at => nil (or whatever your destroyed_field
+ # and field_not_destroyed are). All exceptions require using
+ # exclusive_scope (see self.delete_all, self.count_with_destroyed,
+ # and self.find_with_destroyed )
+ default_scope :conditions => {destroyed_field => field_not_destroyed}
+
+ # Actually delete the model, bypassing the safety net. Because
+ # this method is called internally by Model.delete(id) and on the
+ # delete method in each instance, we don't need to specify those
+ # methods separately
+ def self.delete_all conditions = nil
+ self.with_exclusive_scope { super conditions }
+ end
+
+ # Mark the model deleted_at as now.
+ def destroy_without_callbacks
+ self.class.update_all(
+ "#{destroyed_field} = #{self.class.connection.quote(( field_destroyed.respond_to?(:call) ? field_destroyed.call : field_destroyed))}",
+ "id = #{self.id}"
+ )
+ end
+
+ # Override the default destroy to allow us to flag deleted_at.
+ # This preserves the before_destroy and after_destroy callbacks.
+ # Because this is also called internally by Model.destroy_all and
+ # the Model.destroy(id), we don't need to specify those methods
+ # separately.
+ def destroy
+ return false if callback(:before_destroy) == false
+ result = destroy_without_callbacks
+ callback(:after_destroy)
+ result
+ end
+
+ # Use update_all with an exclusive scope to restore undo the soft-delete.
+ # This bypasses update-related callbacks.
+ #
+ # By default, restores cascade through associations that are
+ # :dependent => :destroy and under is_paranoid. You can prevent restoration
+ # of associated models by passing :include_destroyed_dependents => false,
+ # for example:
+ # Android.restore(:include_destroyed_dependents => false)
+ def self.restore(id, options = {})
+ options.reverse_merge!({:include_destroyed_dependents => true})
+ with_exclusive_scope do
+ update_all(
+ "#{destroyed_field} = #{connection.quote(field_not_destroyed)}",
+ "id = #{id}"
+ )
+ end
+ if options[:include_destroyed_dependents]
+ self.reflect_on_all_associations.each do |association|
+ if association.options[:dependent] == :destroy and association.klass.respond_to?(:restore)
+ association.klass.find_destroyed_only(:all,
+ :conditions => ["#{association.primary_key_name} = ?", id]
+ ).each do |model|
+ model.restore
+ end
+ end
+ end
+ end
+ end
+
+ # Set deleted_at flag on a model to field_not_destroyed, effectively
+ # undoing the soft-deletion.
+ def restore(options = {})
+ self.class.restore(id, options)
+ end
+
+ # find_with_destroyed and other blah_with_destroyed and
+ # blah_destroyed_only methods are defined here
+ def self.method_missing name, *args
+ if name.to_s =~ /^(.*)(_destroyed_only|_with_destroyed)$/ and self.respond_to?($1)
+ self.extend(Module.new{
+ if $2 == '_with_destroyed' # Example:
+ define_method name do |*args| # def count_with_destroyed(*args)
+ self.with_exclusive_scope{ self.send($1, *args) } # self.with_exclusive_scope{ self.send(:count, *args) }
+ end # end
+ else
+ # Example:
+ # def count_destroyed_only(*args)
+ # self.with_exclusive_scope do
+ # with_scope({:find => { :conditions => ["#{destroyed_field} IS NOT ?", nil] }}) do
+ # self.send(:count, *args)
+ # end
+ # end
+ # end
+ define_method name do |*args|
+ self.with_exclusive_scope do
+ with_scope({:find => { :conditions => ["#{self.table_name}.#{destroyed_field} IS NOT ?", field_not_destroyed] }}) do
+ self.send($1, *args)
+ end
+ end
+ end
+ end
+ })
+ self.send(name, *args)
+ else
+ super(name, *args)
+ end
+ end
end
end
-
- # Return a count that includes the soft-deleted models.
- def count_with_destroyed *args
- self.with_exclusive_scope { count(*args) }
- end
-
- # Return instances of all models matching the query regardless
- # of whether or not they have been soft-deleted.
- def find_with_destroyed *args
- self.with_exclusive_scope { find(*args) }
- end
- end
-
- module InstanceMethods
- # Mark the model deleted_at as now.
- def destroy_without_callbacks
- self.update_attribute(:deleted_at, Time.now.utc)
- end
-
- # Override the default destroy to allow us to flag deleted_at.
- # This preserves the before_destroy and after_destroy callbacks.
- # Because this is also called internally by Model.destroy_all and
- # the Model.destroy(id), we don't need to specify those methods
- # separately.
- def destroy
- return false if callback(:before_destroy) == false
- result = destroy_without_callbacks
- callback(:after_destroy)
- result
- end
-
- # Set deleted_at flag on a model to nil, effectively undoing the
- # soft-deletion.
- def restore
- self.update_attribute(:deleted_at, nil)
- end
end
-
end
-
-ActiveRecord::Base.send(:include, IsParanoid)
+
+ActiveRecord::Base.send(:extend, IsParanoid)
Oops, something went wrong.

0 comments on commit e1c7adb

Please sign in to comment.