goldstar-is_paranoid 0.9.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG ADDED
@@ -0,0 +1,31 @@
1
+ This will only document major changes. Please see the commit log for minor changes.
2
+
3
+ -2009-06-13
4
+ * added support for is_paranoid conditions being maintained on preloaded associations
5
+ * destroy and restore now return self (to be more in keeping with other ActiveRecord methods) via Brent Dillingham
6
+
7
+ -2009-05-19
8
+ * added support for specifying relationships to restore via instance_model.restore(:include => [:parent_1, :child_1, :child_2]), etc.
9
+ * method_missing is no longer overridden on instances provided you declare your custom method_missing *before* specifying the model is_paranoid
10
+
11
+ -2009-05-12
12
+ * added support for parent_with_destroyed methods
13
+
14
+ -2009-04-27
15
+ * restoring models now cascades to child dependent => destroy models via Matt Todd
16
+
17
+ -2009-04-22
18
+ * destroying and restoring records no longer triggers saving/updating callbacks
19
+
20
+ -2009-03-28
21
+ * removing syntax for calculation require (all find and ActiveRecord calculations are done on-the-fly now via method_missing)
22
+ * adding ability to specify alternate fields and values for destroyed objects
23
+ * adding in support for _destroyed_only methods (with inspiration from David Krmpotic)
24
+ * adding init.rb via David Krmpotic
25
+ * adding jewler tasks via Scott Woods
26
+
27
+ -2009-03-24
28
+ * requiring specific syntax to include calculations
29
+
30
+ -2009-03-21
31
+ * initial release
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2009 Jeffrey Chupp
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,103 @@
1
+ h1. is_paranoid ( same as it ever was )
2
+
3
+ h3. and you may ask yourself, well, how did I get here?
4
+
5
+ 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*.
6
+
7
+ h3. and you may ask yourself, how do I work this?
8
+
9
+ 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:
10
+
11
+ 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.
12
+
13
+ So let's assume we have a model Automobile that has a deleted_at column on the automobiles table.
14
+
15
+ 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).
16
+
17
+ <pre>
18
+ Rails::Initializer.run do |config|
19
+ # ...
20
+ config.gem "jchupp-is_paranoid", :lib => 'is_paranoid', :version => ">= 0.0.1"
21
+ end
22
+ </pre>
23
+
24
+ Then in your ActiveRecord model
25
+
26
+ <pre>
27
+ class Automobile < ActiveRecord::Base
28
+ is_paranoid
29
+ end
30
+ </pre>
31
+
32
+ Now our automobiles are now soft-deleteable.
33
+
34
+ <pre>
35
+ that_large_automobile = Automobile.create()
36
+ Automobile.count # => 1
37
+
38
+ that_large_automobile.destroy
39
+ Automobile.count # => 0
40
+ Automobile.count_with_destroyed # => 1
41
+
42
+ # where is that large automobile?
43
+ that_large_automobile = Automobile.find_with_destroyed(:all).first
44
+ that_large_automobile.restore
45
+ Automobile.count # => 1
46
+ </pre>
47
+
48
+ 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.
49
+
50
+ <pre>
51
+ Automobile.destroy_all
52
+ Automobile.count # => 0
53
+ Automobile.count_with_destroyed # => 1
54
+
55
+ Automobile.delete_all
56
+ Automobile.count_with_destroyed # => 0
57
+ # And you may say to yourself, "My god! What have I done?"
58
+ </pre>
59
+
60
+ 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.
61
+
62
+ h3. Specifying alternate rules for what should be considered destroyed
63
+
64
+ "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:
65
+
66
+ <pre>
67
+ class Pirate < ActiveRecord::Base
68
+ is_paranoid :field => [:alive, false, true]
69
+ end
70
+
71
+ class DeadPirate < ActiveRecord::Base
72
+ set_table_name :pirates
73
+ is_paranoid :field => [:alive, true, false]
74
+ end
75
+ </pre>
76
+
77
+ 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.
78
+
79
+ h3. Note about validates_uniqueness_of:
80
+
81
+ 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:
82
+
83
+ <pre>
84
+ class Android < ActiveRecord::Base
85
+ validates_uniqueness_of :name, :scope => :deleted_at
86
+ is_paranoid
87
+ end
88
+ </pre>
89
+
90
+ And now the validates_uniqueness_of will ignore items that are destroyed.
91
+
92
+ h3. and you may ask yourself, where does that highway go to?
93
+
94
+ 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.
95
+
96
+ Currently on the todo list:
97
+ * add options for merging additional default_scope options (i.e. order, etc.)
98
+
99
+ h3. Thanks
100
+
101
+ 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.
102
+
103
+ [defscope]http://ryandaigle.com/articles/2008/11/18/what-s-new-in-edge-rails-default-scoping
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ require "spec"
2
+ require "spec/rake/spectask"
3
+ require 'lib/is_paranoid.rb'
4
+
5
+ Spec::Rake::SpecTask.new do |t|
6
+ t.spec_opts = ['--options', "\"#{File.dirname(__FILE__)}/spec/spec.opts\""]
7
+ t.spec_files = FileList['spec/**/*_spec.rb']
8
+ end
9
+
10
+ begin
11
+ require 'jeweler'
12
+ Jeweler::Tasks.new do |s|
13
+ s.name = %q{goldstar-is_paranoid}
14
+ 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. This particular Goldstar version is patched to not emit warnings with Rails 2.3.10.}
15
+ s.email = %q{jeff@semanticart.com}
16
+ s.homepage = %q{http://github.com/jchupp/is_paranoid/}
17
+ s.description = ""
18
+ s.authors = ["Jeffrey Chupp"]
19
+ end
20
+ rescue LoadError
21
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
22
+ end
23
+
24
+ task :default => :spec
data/VERSION.yml ADDED
@@ -0,0 +1,5 @@
1
+ ---
2
+ :patch: 0
3
+ :major: 0
4
+ :build: 1
5
+ :minor: 9
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'is_paranoid'
@@ -0,0 +1,49 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{is_paranoid}
5
+ s.version = "0.9.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Jeffrey Chupp"]
9
+ s.date = %q{2009-06-13}
10
+ s.description = %q{}
11
+ s.email = %q{jeff@semanticart.com}
12
+ s.extra_rdoc_files = [
13
+ "README.textile"
14
+ ]
15
+ s.files = [
16
+ "README.textile",
17
+ "Rakefile",
18
+ "VERSION.yml",
19
+ "lib/is_paranoid.rb",
20
+ "spec/database.yml",
21
+ "spec/is_paranoid_spec.rb",
22
+ "spec/models.rb",
23
+ "spec/schema.rb",
24
+ "spec/spec.opts",
25
+ "spec/spec_helper.rb"
26
+ ]
27
+ s.has_rdoc = true
28
+ s.homepage = %q{http://github.com/jchupp/is_paranoid/}
29
+ s.rdoc_options = ["--charset=UTF-8"]
30
+ s.require_paths = ["lib"]
31
+ s.rubygems_version = %q{1.3.2}
32
+ 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.}
33
+ s.test_files = [
34
+ "spec/is_paranoid_spec.rb",
35
+ "spec/models.rb",
36
+ "spec/schema.rb",
37
+ "spec/spec_helper.rb"
38
+ ]
39
+
40
+ if s.respond_to? :specification_version then
41
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
42
+ s.specification_version = 3
43
+
44
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
45
+ else
46
+ end
47
+ else
48
+ end
49
+ end
@@ -0,0 +1,228 @@
1
+ require 'active_record'
2
+
3
+ module IsParanoid
4
+ # Call this in your model to enable all the safety-net goodness
5
+ #
6
+ # Example:
7
+ #
8
+ # class Android < ActiveRecord::Base
9
+ # is_paranoid
10
+ # end
11
+ #
12
+
13
+ def is_paranoid opts = {}
14
+ opts[:field] ||= [:deleted_at, Proc.new{Time.now.utc}, nil]
15
+ class_inheritable_accessor :destroyed_field, :field_destroyed, :field_not_destroyed
16
+ self.destroyed_field, self.field_destroyed, self.field_not_destroyed = opts[:field]
17
+
18
+ # This is the real magic. All calls made to this model will append
19
+ # the conditions deleted_at => nil (or whatever your destroyed_field
20
+ # and field_not_destroyed are). All exceptions require using
21
+ # exclusive_scope (see self.delete_all, self.count_with_destroyed,
22
+ # and self.find_with_destroyed defined in the module ClassMethods)
23
+ default_scope :conditions => {destroyed_field => field_not_destroyed}
24
+
25
+ extend ClassMethods
26
+ include InstanceMethods
27
+ end
28
+
29
+ module ClassMethods
30
+ # Actually delete the model, bypassing the safety net. Because
31
+ # this method is called internally by Model.delete(id) and on the
32
+ # delete method in each instance, we don't need to specify those
33
+ # methods separately
34
+ def delete_all conditions = nil
35
+ self.with_exclusive_scope { super conditions }
36
+ end
37
+
38
+ # Use update_all with an exclusive scope to restore undo the soft-delete.
39
+ # This bypasses update-related callbacks.
40
+ #
41
+ # By default, restores cascade through associations that are belongs_to
42
+ # :dependent => :destroy and under is_paranoid. You can prevent restoration
43
+ # of associated models by passing :include_destroyed_dependents => false,
44
+ # for example:
45
+ #
46
+ # Android.restore(:include_destroyed_dependents => false)
47
+ #
48
+ # Alternatively you can specify which relationships to restore via :include,
49
+ # for example:
50
+ #
51
+ # Android.restore(:include => [:parts, memories])
52
+ #
53
+ # Please note that specifying :include means you're not using
54
+ # :include_destroyed_dependents by default, though you can explicitly use
55
+ # both if you want all has_* relationships and specific belongs_to
56
+ # relationships, for example
57
+ #
58
+ # Android.restore(:include => [:home, :planet], :include_destroyed_dependents => true)
59
+ def restore(id, options = {})
60
+ options.reverse_merge!({:include_destroyed_dependents => true}) unless options[:include]
61
+ with_exclusive_scope do
62
+ update_all(
63
+ "#{destroyed_field} = #{connection.quote(field_not_destroyed)}",
64
+ "id = #{id}"
65
+ )
66
+ end
67
+
68
+ self.reflect_on_all_associations.each do |association|
69
+ if association.options[:dependent] == :destroy and association.klass.respond_to?(:restore)
70
+ dependent_relationship = association.macro.to_s =~ /^has/
71
+ if should_restore?(association.name, dependent_relationship, options)
72
+ if dependent_relationship
73
+ restore_related(association.klass, association.primary_key_name, id, options)
74
+ else
75
+ restore_related(
76
+ association.klass,
77
+ association.klass.primary_key,
78
+ self.first(id).send(association.primary_key_name),
79
+ options
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ # find_with_destroyed and other blah_with_destroyed and
88
+ # blah_destroyed_only methods are defined here
89
+ def method_missing name, *args, &block
90
+ if name.to_s =~ /^(.*)(_destroyed_only|_with_destroyed)$/ and self.respond_to?($1)
91
+ self.extend(Module.new{
92
+ if $2 == '_with_destroyed'
93
+ # Example:
94
+ # def count_with_destroyed(*args)
95
+ # self.with_exclusive_scope{ self.send(:count, *args) }
96
+ # end
97
+ define_method name do |*args|
98
+ self.with_exclusive_scope{ self.send($1, *args) }
99
+ end
100
+ else
101
+
102
+ # Example:
103
+ # def count_destroyed_only(*args)
104
+ # self.with_exclusive_scope do
105
+ # with_scope({:find => { :conditions => ["#{destroyed_field} IS NOT ?", nil] }}) do
106
+ # self.send(:count, *args)
107
+ # end
108
+ # end
109
+ # end
110
+ define_method name do |*args|
111
+ self.with_exclusive_scope do
112
+ with_scope({:find => { :conditions => ["#{self.table_name}.#{destroyed_field} IS NOT ?", field_not_destroyed] }}) do
113
+ self.send($1, *args, &block)
114
+ end
115
+ end
116
+ end
117
+
118
+ end
119
+ })
120
+ self.send(name, *args, &block)
121
+ else
122
+ super(name, *args, &block)
123
+ end
124
+ end
125
+
126
+ # with_exclusive_scope is used internally by ActiveRecord when preloading
127
+ # associations. Unfortunately this is problematic for is_paranoid since we
128
+ # want preloaded is_paranoid items to still be scoped to their deleted conditions.
129
+ # so we override that here.
130
+ def with_exclusive_scope(method_scoping = {}, &block)
131
+ # this is rather hacky, suggestions for improvements appreciated... the idea
132
+ # is that when the caller includes the method preload_associations, we want
133
+ # to apply our is_paranoid conditions
134
+ if caller.any?{|c| c =~ /\d+:in `preload_associations'$/}
135
+ method_scoping.deep_merge!(:find => {:conditions => {destroyed_field => field_not_destroyed} })
136
+ end
137
+ super method_scoping, &block
138
+ end
139
+
140
+ protected
141
+
142
+ def should_restore?(association_name, dependent_relationship, options) #:nodoc:
143
+ ([*options[:include]] || []).include?(association_name) or
144
+ (options[:include_destroyed_dependents] and dependent_relationship)
145
+ end
146
+
147
+ def restore_related klass, key_name, id, options #:nodoc:
148
+ klass.find_destroyed_only(:all,
149
+ :conditions => ["#{key_name} = ?", id]
150
+ ).each do |model|
151
+ model.restore(options)
152
+ end
153
+ end
154
+ end
155
+
156
+ module InstanceMethods
157
+ def self.included(base)
158
+ base.class_eval do
159
+ unless method_defined? :method_missing
160
+ def method_missing(meth, *args, &block); super; end
161
+ end
162
+ alias_method :old_method_missing, :method_missing
163
+ alias_method :method_missing, :is_paranoid_method_missing
164
+ end
165
+ end
166
+
167
+ def is_paranoid_method_missing name, *args, &block
168
+ # if we're trying for a _____with_destroyed method
169
+ # and we can respond to the _____ method
170
+ # and we have an association by the name of _____
171
+ if name.to_s =~ /^(.*)(_with_destroyed)$/ and
172
+ self.respond_to?($1) and
173
+ (assoc = self.class.reflect_on_all_associations.detect{|a| a.name.to_s == $1})
174
+
175
+ parent_klass = Object.module_eval("::#{assoc.class_name}", __FILE__, __LINE__)
176
+
177
+ self.class.send(
178
+ :include,
179
+ Module.new{ # Example:
180
+ define_method name do |*args| # def android_with_destroyed
181
+ parent_klass.first_with_destroyed( # Android.first_with_destroyed(
182
+ :conditions => { # :conditions => {
183
+ parent_klass.primary_key => # :id =>
184
+ self.send(assoc.primary_key_name) # self.send(:android_id)
185
+ } # }
186
+ ) # )
187
+ end # end
188
+ }
189
+ )
190
+ self.send(name, *args, &block)
191
+ else
192
+ old_method_missing(name, *args, &block)
193
+ end
194
+ end
195
+
196
+ # Mark the model deleted_at as now.
197
+ def destroy_without_callbacks
198
+ self.class.update_all(
199
+ "#{destroyed_field} = #{self.class.connection.quote(( field_destroyed.respond_to?(:call) ? field_destroyed.call : field_destroyed))}",
200
+ "id = #{self.id}"
201
+ )
202
+ self
203
+ end
204
+
205
+ # Override the default destroy to allow us to flag deleted_at.
206
+ # This preserves the before_destroy and after_destroy callbacks.
207
+ # Because this is also called internally by Model.destroy_all and
208
+ # the Model.destroy(id), we don't need to specify those methods
209
+ # separately.
210
+ def destroy
211
+ return false if callback(:before_destroy) == false
212
+ result = destroy_without_callbacks
213
+ callback(:after_destroy)
214
+ self
215
+ end
216
+
217
+ # Set deleted_at flag on a model to field_not_destroyed, effectively
218
+ # undoing the soft-deletion.
219
+ def restore(options = {})
220
+ self.class.restore(id, options)
221
+ self
222
+ end
223
+
224
+ end
225
+
226
+ end
227
+
228
+ ActiveRecord::Base.send(:extend, IsParanoid)
data/spec/database.yml ADDED
@@ -0,0 +1,3 @@
1
+ test:
2
+ :adapter: sqlite3
3
+ :dbfile: is_paranoid.db
@@ -0,0 +1,277 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+ require File.expand_path(File.dirname(__FILE__) + '/models')
3
+
4
+ LUKE = 'Luke Skywalker'
5
+
6
+ describe IsParanoid do
7
+ before(:each) do
8
+ Android.delete_all
9
+ Person.delete_all
10
+ Component.delete_all
11
+
12
+ @luke = Person.create(:name => LUKE)
13
+ @r2d2 = Android.create(:name => 'R2D2', :owner_id => @luke.id)
14
+ @c3p0 = Android.create(:name => 'C3P0', :owner_id => @luke.id)
15
+
16
+ @r2d2.components.create(:name => 'Rotors')
17
+
18
+ @r2d2.memories.create(:name => 'A pretty sunset')
19
+ @c3p0.sticker = Sticker.create(:name => 'OMG, PONIES!')
20
+ end
21
+
22
+ describe 'non-is_paranoid models' do
23
+ it "should destroy as normal" do
24
+ lambda{
25
+ @luke.destroy
26
+ }.should change(Person, :count).by(-1)
27
+
28
+ lambda{
29
+ Person.count_with_destroyed
30
+ }.should raise_error(NoMethodError)
31
+ end
32
+ end
33
+
34
+ describe 'destroying' do
35
+ it "should soft-delete a record" do
36
+ lambda{
37
+ Android.destroy(@r2d2.id)
38
+ }.should change(Android, :count).from(2).to(1)
39
+ Android.count_with_destroyed.should == 2
40
+ end
41
+
42
+ it "should not hit update/save related callbacks" do
43
+ lambda{
44
+ Android.first.update_attribute(:name, 'Robocop')
45
+ }.should raise_error
46
+
47
+ lambda{
48
+ Android.first.destroy
49
+ }.should_not raise_error
50
+ end
51
+
52
+ it "should soft-delete matching items on Model.destroy_all" do
53
+ lambda{
54
+ Android.destroy_all("owner_id = #{@luke.id}")
55
+ }.should change(Android, :count).from(2).to(0)
56
+ Android.count_with_destroyed.should == 2
57
+ end
58
+
59
+ describe 'related models' do
60
+ it "should no longer show up in the relationship to the owner" do
61
+ @luke.androids.size.should == 2
62
+ @r2d2.destroy
63
+ @luke.androids.size.should == 1
64
+ end
65
+
66
+ it "should soft-delete on dependent destroys" do
67
+ lambda{
68
+ @luke.destroy
69
+ }.should change(Android, :count).from(2).to(0)
70
+ Android.count_with_destroyed.should == 2
71
+ end
72
+ end
73
+ end
74
+
75
+ describe 'finding destroyed models' do
76
+ it "should be able to find destroyed items via #find_with_destroyed" do
77
+ @r2d2.destroy
78
+ Android.find(:first, :conditions => {:name => 'R2D2'}).should be_blank
79
+ Android.first_with_destroyed(:conditions => {:name => 'R2D2'}).should_not be_blank
80
+ end
81
+
82
+ it "should be able to find only destroyed items via #find_destroyed_only" do
83
+ @r2d2.destroy
84
+ Android.all_destroyed_only.size.should == 1
85
+ Android.first_destroyed_only.should == @r2d2
86
+ end
87
+
88
+ it "should not show destroyed models via :include" do
89
+ Person.first(:conditions => {:name => LUKE}, :include => :androids).androids.size.should == 2
90
+ @r2d2.destroy
91
+ person = Person.first(:conditions => {:name => LUKE}, :include => :androids)
92
+ # ensure that we're using the preload and not loading it via a find
93
+ Android.should_not_receive(:find)
94
+ person.androids.size.should == 1
95
+ end
96
+ end
97
+
98
+ describe 'calculations' do
99
+ it "should have a proper count inclusively and exclusively of destroyed items" do
100
+ @r2d2.destroy
101
+ @c3p0.destroy
102
+ Android.count.should == 0
103
+ Android.count_with_destroyed.should == 2
104
+ end
105
+
106
+ it "should respond to various calculations" do
107
+ @r2d2.destroy
108
+ Android.sum('id').should == @c3p0.id
109
+ Android.sum_with_destroyed('id').should == @r2d2.id + @c3p0.id
110
+ Android.average_with_destroyed('id').should == (@r2d2.id + @c3p0.id) / 2.0
111
+ end
112
+ end
113
+
114
+ describe 'deletion' do
115
+ it "should actually remove records on #delete_all" do
116
+ lambda{
117
+ Android.delete_all
118
+ }.should change(Android, :count_with_destroyed).from(2).to(0)
119
+ end
120
+
121
+ it "should actually remove records on #delete" do
122
+ lambda{
123
+ Android.first.delete
124
+ }.should change(Android, :count_with_destroyed).from(2).to(1)
125
+ end
126
+ end
127
+
128
+ describe 'restore' do
129
+ it "should allow restoring soft-deleted items" do
130
+ @r2d2.destroy
131
+ lambda{
132
+ @r2d2.restore
133
+ }.should change(Android, :count).from(1).to(2)
134
+ end
135
+
136
+ it "should not hit update/save related callbacks" do
137
+ @r2d2.destroy
138
+
139
+ lambda{
140
+ @r2d2.update_attribute(:name, 'Robocop')
141
+ }.should raise_error
142
+
143
+ lambda{
144
+ @r2d2.restore
145
+ }.should_not raise_error
146
+ end
147
+
148
+ it "should restore dependent models when being restored by default" do
149
+ @r2d2.destroy
150
+ lambda{
151
+ @r2d2.restore
152
+ }.should change(Component, :count).from(0).to(1)
153
+ end
154
+
155
+ it "should provide the option to not restore dependent models" do
156
+ @r2d2.destroy
157
+ lambda{
158
+ @r2d2.restore(:include_destroyed_dependents => false)
159
+ }.should_not change(Component, :count)
160
+ end
161
+
162
+ it "should restore parent and child models specified via :include" do
163
+ sub_component = SubComponent.create(:name => 'part', :component_id => @r2d2.components.first.id)
164
+ @r2d2.destroy
165
+ SubComponent.first(:conditions => {:id => sub_component.id}).should be_nil
166
+ @r2d2.components.first.restore(:include => [:android, :sub_components])
167
+ SubComponent.first(:conditions => {:id => sub_component.id}).should_not be_nil
168
+ Android.find(@r2d2.id).should_not be_nil
169
+ end
170
+ end
171
+
172
+ describe 'validations' do
173
+ it "should not ignore destroyed items in validation checks unless scoped" do
174
+ # Androids are not validates_uniqueness_of scoped
175
+ @r2d2.destroy
176
+ lambda{
177
+ Android.create!(:name => 'R2D2')
178
+ }.should raise_error(ActiveRecord::RecordInvalid)
179
+
180
+ lambda{
181
+ # creating shouldn't raise an error
182
+ another_r2d2 = AndroidWithScopedUniqueness.create!(:name => 'R2D2')
183
+ # neither should destroying the second incarnation since the
184
+ # validates_uniqueness_of is only applied on create
185
+ another_r2d2.destroy
186
+ }.should_not raise_error
187
+ end
188
+ end
189
+
190
+ describe '(parent)_with_destroyed' do
191
+ it "should be able to access destroyed parents" do
192
+ # Memory is has_many with a non-default primary key
193
+ # Sticker is a has_one with a default primary key
194
+ [Memory, Sticker].each do |klass|
195
+ instance = klass.last
196
+ parent = instance.android
197
+ instance.android.destroy
198
+
199
+ # reload so the model doesn't remember the parent
200
+ instance.reload
201
+ instance.android.should == nil
202
+ instance.android_with_destroyed.should == parent
203
+ end
204
+ end
205
+
206
+ it "should return nil if no destroyed parent exists" do
207
+ sticker = Sticker.new(:name => 'Rainbows')
208
+ # because the default relationship works this way, i.e.
209
+ sticker.android.should == nil
210
+ sticker.android_with_destroyed.should == nil
211
+ end
212
+
213
+ it "should not break method_missing's defined before the is_paranoid call" do
214
+ # we've defined a method_missing on Sticker
215
+ # that changes the sticker name.
216
+ sticker = Sticker.new(:name => "Ponies!")
217
+ lambda{
218
+ sticker.some_crazy_method_that_we_certainly_do_not_respond_to
219
+ }.should change(sticker, :name).to(Sticker::MM_NAME)
220
+ end
221
+ end
222
+
223
+ describe 'alternate fields and field values' do
224
+ it "should properly function for boolean values" do
225
+ # ninjas are invisible by default. not being ninjas, we can only
226
+ # find those that are visible
227
+ ninja = Ninja.create(:name => 'Esteban', :visible => true)
228
+ ninja.vanish # aliased to destroy
229
+ Ninja.first.should be_blank
230
+ Ninja.find_with_destroyed(:first).should == ninja
231
+ Ninja.count.should == 0
232
+
233
+ # we're only interested in pirates who are alive by default
234
+ pirate = Pirate.create(:name => 'Reginald')
235
+ pirate.destroy
236
+ Pirate.first.should be_blank
237
+ Pirate.find_with_destroyed(:first).should == pirate
238
+ Pirate.count.should == 0
239
+
240
+ # we're only interested in pirates who are dead by default.
241
+ # zombie pirates ftw!
242
+ DeadPirate.first.id.should == pirate.id
243
+ lambda{
244
+ DeadPirate.first.destroy
245
+ }.should change(Pirate, :count).from(0).to(1)
246
+ DeadPirate.count.should == 0
247
+ end
248
+ end
249
+
250
+ describe 'after_destroy and before_destroy callbacks' do
251
+ it "should rollback if before_destroy fails" do
252
+ edward = UndestroyablePirate.create(:name => 'Edward')
253
+ lambda{
254
+ edward.destroy
255
+ }.should_not change(UndestroyablePirate, :count)
256
+ end
257
+
258
+ it "should rollback if after_destroy raises an error" do
259
+ raul = RandomPirate.create(:name => 'Raul')
260
+ lambda{
261
+ begin
262
+ raul.destroy
263
+ rescue => ex
264
+ ex.message.should == 'after_destroy works'
265
+ end
266
+ }.should_not change(RandomPirate, :count)
267
+ end
268
+
269
+ it "should handle callbacks normally assuming no failures are encountered" do
270
+ component = Component.first
271
+ lambda{
272
+ component.destroy
273
+ }.should change(component, :name).to(Component::NEW_NAME)
274
+ end
275
+
276
+ end
277
+ end
data/spec/models.rb ADDED
@@ -0,0 +1,94 @@
1
+ class Person < ActiveRecord::Base #:nodoc:
2
+ validates_uniqueness_of :name
3
+ has_many :androids, :foreign_key => :owner_id, :dependent => :destroy
4
+ end
5
+
6
+ class Android < ActiveRecord::Base #:nodoc:
7
+ validates_uniqueness_of :name
8
+ has_many :components, :dependent => :destroy
9
+ has_one :sticker
10
+ has_many :memories, :foreign_key => 'parent_id'
11
+
12
+ is_paranoid
13
+
14
+ # this code is to ensure that our destroy and restore methods
15
+ # work without triggering before/after_update callbacks
16
+ before_update :raise_hell
17
+ def raise_hell
18
+ raise "hell"
19
+ end
20
+ end
21
+
22
+ class Component < ActiveRecord::Base #:nodoc:
23
+ is_paranoid
24
+ belongs_to :android, :dependent => :destroy
25
+ has_many :sub_components, :dependent => :destroy
26
+ NEW_NAME = 'Something Else!'
27
+
28
+ after_destroy :change_name
29
+ def change_name
30
+ self.update_attribute(:name, NEW_NAME)
31
+ end
32
+ end
33
+
34
+ class SubComponent < ActiveRecord::Base #:nodoc:
35
+ is_paranoid
36
+ belongs_to :component, :dependent => :destroy
37
+ end
38
+
39
+ class Memory < ActiveRecord::Base #:nodoc:
40
+ is_paranoid
41
+ belongs_to :android, :class_name => "Android", :foreign_key => "parent_id"
42
+ end
43
+
44
+ class Sticker < ActiveRecord::Base #:nodoc
45
+ MM_NAME = "You've got method_missing"
46
+
47
+ # this simply serves to ensure that we don't break method_missing
48
+ # if it is implemented on a class and called before is_paranoid
49
+ def method_missing name, *args, &block
50
+ self.name = MM_NAME
51
+ end
52
+
53
+ is_paranoid
54
+ belongs_to :android
55
+ end
56
+
57
+ class AndroidWithScopedUniqueness < ActiveRecord::Base #:nodoc:
58
+ set_table_name :androids
59
+ validates_uniqueness_of :name, :scope => :deleted_at
60
+ is_paranoid
61
+ end
62
+
63
+ class Ninja < ActiveRecord::Base #:nodoc:
64
+ validates_uniqueness_of :name, :scope => :visible
65
+ is_paranoid :field => [:visible, false, true]
66
+
67
+ alias_method :vanish, :destroy
68
+ end
69
+
70
+ class Pirate < ActiveRecord::Base #:nodoc:
71
+ is_paranoid :field => [:alive, false, true]
72
+ end
73
+
74
+ class DeadPirate < ActiveRecord::Base #:nodoc:
75
+ set_table_name :pirates
76
+ is_paranoid :field => [:alive, true, false]
77
+ end
78
+
79
+ class RandomPirate < ActiveRecord::Base #:nodoc:
80
+ set_table_name :pirates
81
+
82
+ def after_destroy
83
+ raise 'after_destroy works'
84
+ end
85
+ end
86
+
87
+ class UndestroyablePirate < ActiveRecord::Base #:nodoc:
88
+ set_table_name :pirates
89
+ is_paranoid :field => [:alive, false, true]
90
+
91
+ def before_destroy
92
+ false
93
+ end
94
+ end
data/spec/schema.rb ADDED
@@ -0,0 +1,51 @@
1
+ ActiveRecord::Schema.define(:version => 20090317164830) do
2
+ create_table "androids", :force => true do |t|
3
+ t.string "name"
4
+ t.integer "owner_id"
5
+ t.datetime "deleted_at"
6
+ t.datetime "created_at"
7
+ t.datetime "updated_at"
8
+ end
9
+
10
+ create_table "people", :force => true do |t|
11
+ t.string "name"
12
+ t.datetime "created_at"
13
+ t.datetime "updated_at"
14
+ end
15
+
16
+ create_table "components", :force => true do |t|
17
+ t.string "name"
18
+ t.integer "android_id"
19
+ t.datetime "deleted_at"
20
+ t.datetime "created_at"
21
+ t.datetime "updated_at"
22
+ end
23
+
24
+ create_table "sub_components", :force => true do |t|
25
+ t.string "name"
26
+ t.integer "component_id"
27
+ t.datetime "deleted_at"
28
+ end
29
+
30
+ create_table "memories", :force => true do |t|
31
+ t.string "name"
32
+ t.integer "parent_id"
33
+ t.datetime "deleted_at"
34
+ end
35
+
36
+ create_table "stickers", :force => true do |t|
37
+ t.string "name"
38
+ t.integer "android_id"
39
+ t.datetime "deleted_at"
40
+ end
41
+
42
+ create_table "ninjas", :force => true do |t|
43
+ t.string "name"
44
+ t.boolean "visible", :default => false
45
+ end
46
+
47
+ create_table "pirates", :force => true do |t|
48
+ t.string "name"
49
+ t.boolean "alive", :default => true
50
+ end
51
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,14 @@
1
+ require 'rubygems'
2
+ require "#{File.dirname(__FILE__)}/../lib/is_paranoid"
3
+ require 'active_record'
4
+ require 'yaml'
5
+ require 'spec'
6
+
7
+ def connect(environment)
8
+ conf = YAML::load(File.open(File.dirname(__FILE__) + '/database.yml'))
9
+ ActiveRecord::Base.establish_connection(conf[environment])
10
+ end
11
+
12
+ # Open ActiveRecord connection
13
+ connect('test')
14
+ load(File.dirname(__FILE__) + "/schema.rb")
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: goldstar-is_paranoid
3
+ version: !ruby/object:Gem::Version
4
+ hash: 5
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 9
9
+ - 0
10
+ - 1
11
+ version: 0.9.0.1
12
+ platform: ruby
13
+ authors:
14
+ - Jeffrey Chupp
15
+ autorequire:
16
+ bindir: bin
17
+ cert_chain: []
18
+
19
+ date: 2010-11-23 00:00:00 -05:00
20
+ default_executable:
21
+ dependencies: []
22
+
23
+ description: ""
24
+ email: jeff@semanticart.com
25
+ executables: []
26
+
27
+ extensions: []
28
+
29
+ extra_rdoc_files:
30
+ - README.textile
31
+ files:
32
+ - CHANGELOG
33
+ - MIT-LICENSE
34
+ - README.textile
35
+ - Rakefile
36
+ - VERSION.yml
37
+ - init.rb
38
+ - is_paranoid.gemspec
39
+ - lib/is_paranoid.rb
40
+ - spec/database.yml
41
+ - spec/is_paranoid_spec.rb
42
+ - spec/models.rb
43
+ - spec/schema.rb
44
+ - spec/spec.opts
45
+ - spec/spec_helper.rb
46
+ has_rdoc: true
47
+ homepage: http://github.com/jchupp/is_paranoid/
48
+ licenses: []
49
+
50
+ post_install_message:
51
+ rdoc_options: []
52
+
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ hash: 3
61
+ segments:
62
+ - 0
63
+ version: "0"
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ hash: 3
70
+ segments:
71
+ - 0
72
+ version: "0"
73
+ requirements: []
74
+
75
+ rubyforge_project:
76
+ rubygems_version: 1.3.7
77
+ signing_key:
78
+ specification_version: 3
79
+ summary: 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. This particular Goldstar version is patched to not emit warnings with Rails 2.3.10.
80
+ test_files:
81
+ - spec/is_paranoid_spec.rb
82
+ - spec/models.rb
83
+ - spec/schema.rb
84
+ - spec/spec_helper.rb