jomz-is_paranoid 0.9.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ is_paranoid.db
2
+ pkg
3
+ *.gem
4
+ doc/
@@ -0,0 +1,43 @@
1
+ This will only document major changes. Please see the commit log for minor changes.
2
+
3
+ -2009-09-25
4
+ * fixing bug with has_many :through not respecting is_paranoid conditions (thanks, Ben Johnson)
5
+
6
+ -2009-09-18
7
+ * making is_paranoid play nice with habtm relationships
8
+
9
+ -2009-09-17
10
+ * fixed when primary key was not "id" (ie: "uuid") via Thibaud Guillaume-Gentil
11
+
12
+ -2009-09-15
13
+ * added support for has_many associations (i.e. @r2d2.components_with_destroyed) via Amiel Martin
14
+
15
+ -2009-06-13
16
+ * added support for is_paranoid conditions being maintained on preloaded associations
17
+ * destroy and restore now return self (to be more in keeping with other ActiveRecord methods) via Brent Dillingham
18
+
19
+ -2009-05-19
20
+ * added support for specifying relationships to restore via instance_model.restore(:include => [:parent_1, :child_1, :child_2]), etc.
21
+ * method_missing is no longer overridden on instances provided you declare your custom method_missing *before* specifying the model is_paranoid
22
+
23
+ -2009-05-12
24
+ * added support for parent_with_destroyed methods
25
+
26
+ -2009-04-27
27
+ * restoring models now cascades to child dependent => destroy models via Matt Todd
28
+
29
+ -2009-04-22
30
+ * destroying and restoring records no longer triggers saving/updating callbacks
31
+
32
+ -2009-03-28
33
+ * removing syntax for calculation require (all find and ActiveRecord calculations are done on-the-fly now via method_missing)
34
+ * adding ability to specify alternate fields and values for destroyed objects
35
+ * adding in support for _destroyed_only methods (with inspiration from David Krmpotic)
36
+ * adding init.rb via David Krmpotic
37
+ * adding jewler tasks via Scott Woods
38
+
39
+ -2009-03-24
40
+ * requiring specific syntax to include calculations
41
+
42
+ -2009-03-21
43
+ * initial release
@@ -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.
@@ -0,0 +1,117 @@
1
+ h1. NOTICE: this library is no longer supported or actively developed by the original author. It never made it to a 1.0 stable version. Use it at your own risk and write lots of tests.
2
+
3
+ You can read more here: http://blog.semanticart.com/killing_is_paranoid/
4
+
5
+ h1. is_paranoid ( same as it ever was )
6
+
7
+ h3. advice and disclaimer
8
+
9
+ You should always declare is_paranoid before any associations in your model unless you have a good reason for doing otherwise. Some relationships might not behave properly if you fail to do so. If you know what you're doing (and have written tests) and want to supress the warning then you can pass :suppress_load_order_warning => true as an option.
10
+
11
+ <pre>
12
+ is_paranoid :suppress_load_order_warning => true
13
+ </pre>
14
+
15
+ You should never expect _any_ library to work or behave exactly how you want it to: test, test, test and file an issue if you have any problems. Bonus points if you include sample failing code. Extra bonus points if you send a pull request that implements a feature/fixes a bug.
16
+
17
+ h3. and you may ask yourself, well, how did I get here?
18
+
19
+ 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*.
20
+
21
+ h3. and you may ask yourself, how do I work this?
22
+
23
+ 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:
24
+
25
+ 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.
26
+
27
+ So let's assume we have a model Automobile that has a deleted_at column on the automobiles table.
28
+
29
+ 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).
30
+
31
+ <pre>
32
+ Rails::Initializer.run do |config|
33
+ # ...
34
+ config.gem "semanticart-is_paranoid", :lib => 'is_paranoid', :version => ">= 0.0.1"
35
+ end
36
+ </pre>
37
+
38
+ Then in your ActiveRecord model
39
+
40
+ <pre>
41
+ class Automobile < ActiveRecord::Base
42
+ is_paranoid
43
+ end
44
+ </pre>
45
+
46
+ Now our automobiles are now soft-deleteable.
47
+
48
+ <pre>
49
+ that_large_automobile = Automobile.create()
50
+ Automobile.count # => 1
51
+
52
+ that_large_automobile.destroy
53
+ Automobile.count # => 0
54
+ Automobile.count_with_destroyed # => 1
55
+
56
+ # where is that large automobile?
57
+ that_large_automobile = Automobile.find_with_destroyed(:all).first
58
+ that_large_automobile.restore
59
+ Automobile.count # => 1
60
+ </pre>
61
+
62
+ 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.
63
+
64
+ <pre>
65
+ Automobile.destroy_all
66
+ Automobile.count # => 0
67
+ Automobile.count_with_destroyed # => 1
68
+
69
+ Automobile.delete_all
70
+ Automobile.count_with_destroyed # => 0
71
+ # And you may say to yourself, "My god! What have I done?"
72
+ </pre>
73
+
74
+ 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.
75
+
76
+ h3. Specifying alternate rules for what should be considered destroyed
77
+
78
+ "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:
79
+
80
+ <pre>
81
+ class Pirate < ActiveRecord::Base
82
+ is_paranoid :field => [:alive, false, true]
83
+ end
84
+
85
+ class DeadPirate < ActiveRecord::Base
86
+ set_table_name :pirates
87
+ is_paranoid :field => [:alive, true, false]
88
+ end
89
+ </pre>
90
+
91
+ 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.
92
+
93
+ h3. Note about validates_uniqueness_of:
94
+
95
+ 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:
96
+
97
+ <pre>
98
+ class Android < ActiveRecord::Base
99
+ validates_uniqueness_of :name, :scope => :deleted_at
100
+ is_paranoid
101
+ end
102
+ </pre>
103
+
104
+ And now the validates_uniqueness_of will ignore items that are destroyed.
105
+
106
+ h3. and you may ask yourself, where does that highway go to?
107
+
108
+ 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 file an issue or send a pull request.
109
+
110
+ Currently on the todo list:
111
+ * add options for merging additional default_scope options (i.e. order, etc.)
112
+
113
+ h3. Thanks
114
+
115
+ 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.
116
+
117
+ [defscope]http://ryandaigle.com/articles/2008/11/18/what-s-new-in-edge-rails-default-scoping
@@ -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{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.}
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
@@ -0,0 +1,5 @@
1
+ ---
2
+ :minor: 9
3
+ :patch: 7
4
+ :build:
5
+ :major: 0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'is_paranoid'
@@ -0,0 +1,57 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{jomz-is_paranoid}
8
+ s.version = "0.9.7"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Jeffrey Chupp"]
12
+ s.date = %q{2009-11-23}
13
+ s.description = %q{}
14
+ s.email = %q{jeff@semanticart.com}
15
+ s.extra_rdoc_files = [
16
+ "README.textile"
17
+ ]
18
+ s.files = [
19
+ ".gitignore",
20
+ "CHANGELOG",
21
+ "MIT-LICENSE",
22
+ "README.textile",
23
+ "Rakefile",
24
+ "VERSION.yml",
25
+ "init.rb",
26
+ "is_paranoid.gemspec",
27
+ "lib/is_paranoid.rb",
28
+ "spec/database.yml",
29
+ "spec/is_paranoid_spec.rb",
30
+ "spec/models.rb",
31
+ "spec/schema.rb",
32
+ "spec/spec.opts",
33
+ "spec/spec_helper.rb"
34
+ ]
35
+ s.homepage = %q{http://github.com/jchupp/is_paranoid/}
36
+ s.rdoc_options = ["--charset=UTF-8"]
37
+ s.require_paths = ["lib"]
38
+ s.rubygems_version = %q{1.3.5}
39
+ 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.}
40
+ s.test_files = [
41
+ "spec/is_paranoid_spec.rb",
42
+ "spec/models.rb",
43
+ "spec/schema.rb",
44
+ "spec/spec_helper.rb"
45
+ ]
46
+
47
+ if s.respond_to? :specification_version then
48
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
49
+ s.specification_version = 3
50
+
51
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
52
+ else
53
+ end
54
+ else
55
+ end
56
+ end
57
+
@@ -0,0 +1,285 @@
1
+ require 'activerecord'
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?
14
+ false
15
+ end
16
+
17
+ def is_paranoid opts = {}
18
+ opts[:field] ||= [:deleted_at, Proc.new{Time.now.utc}, nil]
19
+ class_inheritable_accessor :destroyed_field, :field_destroyed, :field_not_destroyed
20
+ self.destroyed_field, self.field_destroyed, self.field_not_destroyed = opts[:field]
21
+
22
+ if self.reflect_on_all_associations.size > 0 && ! opts[:suppress_load_order_warning]
23
+ warn "is_paranoid warning in class #{self}: You should declare is_paranoid before your associations"
24
+ end
25
+
26
+ # This is the real magic. All calls made to this model will append
27
+ # the conditions deleted_at => nil (or whatever your destroyed_field
28
+ # and field_not_destroyed are). All exceptions require using
29
+ # exclusive_scope (see self.delete_all, self.count_with_destroyed,
30
+ # and self.find_with_destroyed defined in the module ClassMethods)
31
+ default_scope :conditions => {destroyed_field => field_not_destroyed}
32
+
33
+ extend ClassMethods
34
+ include InstanceMethods
35
+ end
36
+
37
+ module ClassMethods
38
+ def is_paranoid?
39
+ true
40
+ end
41
+
42
+ def is_or_equals_not_destroyed
43
+ if [nil, 'NULL'].include?(field_not_destroyed)
44
+ 'IS NULL'
45
+ else
46
+ "= #{field_not_destroyed}"
47
+ end
48
+ end
49
+
50
+ # ensure that we respect the is_paranoid conditions when being loaded as a has_many :through
51
+ # NOTE: this only works if is_paranoid is declared before has_many relationships.
52
+ # Only use is_paranoid conditions when the associated class is also paranoid
53
+ def has_many(association_id, options = {}, &extension)
54
+ association_klass_name = options[:class_name] || options[:source] || association_id
55
+ association_klass = association_klass_name.to_s.classify.try(:constantize)
56
+
57
+ if options.key?(:through) && association_klass.is_paranoid?
58
+ conditions = "#{options[:through].to_s.pluralize}.#{destroyed_field} #{is_or_equals_not_destroyed}"
59
+ options[:conditions] = "(" + [options[:conditions], conditions].compact.join(") AND (") + ")"
60
+ end
61
+ super
62
+ end
63
+
64
+ # Actually delete the model, bypassing the safety net. Because
65
+ # this method is called internally by Model.delete(id) and on the
66
+ # delete method in each instance, we don't need to specify those
67
+ # methods separately
68
+ def delete_all conditions = nil
69
+ self.with_exclusive_scope { super conditions }
70
+ end
71
+
72
+ # Use update_all with an exclusive scope to restore undo the soft-delete.
73
+ # This bypasses update-related callbacks.
74
+ #
75
+ # By default, restores cascade through associations that are belongs_to
76
+ # :dependent => :destroy and under is_paranoid. You can prevent restoration
77
+ # of associated models by passing :include_destroyed_dependents => false,
78
+ # for example:
79
+ #
80
+ # Android.restore(:include_destroyed_dependents => false)
81
+ #
82
+ # Alternatively you can specify which relationships to restore via :include,
83
+ # for example:
84
+ #
85
+ # Android.restore(:include => [:parts, memories])
86
+ #
87
+ # Please note that specifying :include means you're not using
88
+ # :include_destroyed_dependents by default, though you can explicitly use
89
+ # both if you want all has_* relationships and specific belongs_to
90
+ # relationships, for example
91
+ #
92
+ # Android.restore(:include => [:home, :planet], :include_destroyed_dependents => true)
93
+ def restore(id, options = {})
94
+ options.reverse_merge!({:include_destroyed_dependents => true}) unless options[:include]
95
+ with_exclusive_scope do
96
+ update_all(
97
+ "#{destroyed_field} = #{connection.quote(field_not_destroyed)}",
98
+ primary_key.to_sym => id
99
+ )
100
+ end
101
+
102
+ self.reflect_on_all_associations.each do |association|
103
+ if association.options[:dependent] == :destroy and association.klass.respond_to?(:restore)
104
+ dependent_relationship = association.macro.to_s =~ /^has/
105
+ if should_restore?(association.name, dependent_relationship, options)
106
+ if dependent_relationship
107
+ restore_related(association.klass, association.primary_key_name, id, options)
108
+ else
109
+ restore_related(
110
+ association.klass,
111
+ association.klass.primary_key,
112
+ self.first(id).send(association.primary_key_name),
113
+ options
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ # TODO: needs better implementation
122
+ def exists_with_destroyed id
123
+ self.with_exclusive_scope{ exists?(id)}
124
+ end
125
+
126
+ # find_with_destroyed and other blah_with_destroyed and
127
+ # blah_destroyed_only methods are defined here
128
+ def method_missing name, *args, &block
129
+ if name.to_s =~ /^(.*)(_destroyed_only|_with_destroyed)$/ and self.respond_to?($1)
130
+ self.extend(Module.new{
131
+ if $2 == '_with_destroyed'
132
+ # Example:
133
+ # def count_with_destroyed(*args)
134
+ # self.with_exclusive_scope{ self.send(:count, *args) }
135
+ # end
136
+ define_method name do |*args|
137
+ self.with_exclusive_scope{ self.send($1, *args) }
138
+ end
139
+ else
140
+
141
+ # Example:
142
+ # def count_destroyed_only(*args)
143
+ # self.with_exclusive_scope do
144
+ # with_scope({:find => { :conditions => ["#{destroyed_field} IS NOT ?", nil] }}) do
145
+ # self.send(:count, *args)
146
+ # end
147
+ # end
148
+ # end
149
+ define_method name do |*args|
150
+ self.with_exclusive_scope do
151
+ with_scope({:find => { :conditions => ["#{self.table_name}.#{destroyed_field} IS NOT ?", field_not_destroyed] }}) do
152
+ self.send($1, *args, &block)
153
+ end
154
+ end
155
+ end
156
+
157
+ end
158
+ })
159
+ self.send(name, *args, &block)
160
+ else
161
+ super(name, *args, &block)
162
+ end
163
+ end
164
+
165
+ # with_exclusive_scope is used internally by ActiveRecord when preloading
166
+ # associations. Unfortunately this is problematic for is_paranoid since we
167
+ # want preloaded is_paranoid items to still be scoped to their deleted conditions.
168
+ # so we override that here.
169
+ def with_exclusive_scope(method_scoping = {}, &block)
170
+ # this is rather hacky, suggestions for improvements appreciated... the idea
171
+ # is that when the caller includes the method preload_associations, we want
172
+ # to apply our is_paranoid conditions
173
+ if caller.any?{|c| c =~ /\d+:in `preload_associations'$/}
174
+ method_scoping.deep_merge!(:find => {:conditions => {destroyed_field => field_not_destroyed} })
175
+ end
176
+ super method_scoping, &block
177
+ end
178
+
179
+ protected
180
+
181
+ def should_restore?(association_name, dependent_relationship, options) #:nodoc:
182
+ ([*options[:include]] || []).include?(association_name) or
183
+ (options[:include_destroyed_dependents] and dependent_relationship)
184
+ end
185
+
186
+ def restore_related klass, key_name, id, options #:nodoc:
187
+ klass.find_destroyed_only(:all,
188
+ :conditions => ["#{key_name} = ?", id]
189
+ ).each do |model|
190
+ model.restore(options)
191
+ end
192
+ end
193
+ end
194
+
195
+ module InstanceMethods
196
+ def destroyed?
197
+ destroyed_field != nil
198
+ end
199
+
200
+ def self.included(base)
201
+ base.class_eval do
202
+ unless method_defined? :method_missing
203
+ def method_missing(meth, *args, &block); super; end
204
+ end
205
+ alias_method :old_method_missing, :method_missing
206
+ alias_method :method_missing, :is_paranoid_method_missing
207
+ end
208
+ end
209
+
210
+ def is_paranoid_method_missing name, *args, &block
211
+ # if we're trying for a _____with_destroyed method
212
+ # and we can respond to the _____ method
213
+ # and we have an association by the name of _____
214
+ if name.to_s =~ /^(.*)(_with_destroyed)$/ and
215
+ self.respond_to?($1) and
216
+ (assoc = self.class.reflect_on_all_associations.detect{|a| a.name.to_s == $1})
217
+
218
+ parent_klass = Object.module_eval("::#{assoc.class_name}", __FILE__, __LINE__)
219
+
220
+ self.class.send(
221
+ :include,
222
+ Module.new {
223
+ if assoc.macro.to_s =~ /^has/
224
+ parent_method = assoc.macro.to_s =~ /^has_one/ ? 'first_with_destroyed' : 'all_with_destroyed'
225
+ # Example:
226
+ define_method name do |*args| # def android_with_destroyed
227
+ parent_klass.send("#{parent_method}", # Android.all_with_destroyed(
228
+ :conditions => { # :conditions => {
229
+ assoc.primary_key_name => # :person_id =>
230
+ self.send(parent_klass.primary_key) # self.send(:id)
231
+ } # }
232
+ ) # )
233
+ end # end
234
+
235
+ else
236
+ # Example:
237
+ define_method name do |*args| # def android_with_destroyed
238
+ parent_klass.first_with_destroyed( # Android.first_with_destroyed(
239
+ :conditions => { # :conditions => {
240
+ parent_klass.primary_key => # :id =>
241
+ self.send(assoc.primary_key_name) # self.send(:android_id)
242
+ } # }
243
+ ) # )
244
+ end # end
245
+ end
246
+ }
247
+ )
248
+ self.send(name, *args, &block)
249
+ else
250
+ old_method_missing(name, *args, &block)
251
+ end
252
+ end
253
+
254
+ # Mark the model deleted_at as now.
255
+ def alt_destroy_without_callbacks
256
+ self.class.update_all(
257
+ "#{destroyed_field} = #{self.class.connection.quote(( field_destroyed.respond_to?(:call) ? field_destroyed.call : field_destroyed))}",
258
+ self.class.primary_key.to_sym => self.id
259
+ )
260
+ self
261
+ end
262
+
263
+ # Override the default destroy to allow us to flag deleted_at.
264
+ # This preserves the before_destroy and after_destroy callbacks.
265
+ # Because this is also called internally by Model.destroy_all and
266
+ # the Model.destroy(id), we don't need to specify those methods
267
+ # separately.
268
+ def destroy
269
+ return false if callback(:before_destroy) == false
270
+ result = alt_destroy_without_callbacks
271
+ callback(:after_destroy)
272
+ self
273
+ end
274
+
275
+ # Set deleted_at flag on a model to field_not_destroyed, effectively
276
+ # undoing the soft-deletion.
277
+ def restore(options = {})
278
+ self.class.restore(id, options)
279
+ self
280
+ end
281
+
282
+ end
283
+
284
+ end
285
+ ActiveRecord::Base.send(:extend, IsParanoid)
@@ -0,0 +1,3 @@
1
+ test:
2
+ :adapter: sqlite3
3
+ :database: is_paranoid.db
@@ -0,0 +1,337 @@
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
+ @tatooine = Place.create(:name => "Tatooine")
21
+ @r2d2.places << @tatooine
22
+ end
23
+
24
+ describe 'non-is_paranoid models' do
25
+ it 'should not be paranoid' do
26
+ Person.is_paranoid?.should eql(false)
27
+ end
28
+
29
+ it "should destroy as normal" do
30
+ lambda{
31
+ @luke.destroy
32
+ }.should change(Person, :count).by(-1)
33
+
34
+ lambda{
35
+ Person.count_with_destroyed
36
+ }.should raise_error(NoMethodError)
37
+ end
38
+ end
39
+
40
+ describe 'destroying' do
41
+ it "should soft-delete a record" do
42
+ lambda{
43
+ Android.destroy(@r2d2.id)
44
+ }.should change(Android, :count).from(2).to(1)
45
+ Android.count_with_destroyed.should == 2
46
+ end
47
+
48
+ it "should not hit update/save related callbacks" do
49
+ lambda{
50
+ Android.first.update_attribute(:name, 'Robocop')
51
+ }.should raise_error
52
+
53
+ lambda{
54
+ Android.first.destroy
55
+ }.should_not raise_error
56
+ end
57
+
58
+ it "should soft-delete matching items on Model.destroy_all" do
59
+ lambda{
60
+ Android.destroy_all("owner_id = #{@luke.id}")
61
+ }.should change(Android, :count).from(2).to(0)
62
+ Android.count_with_destroyed.should == 2
63
+ end
64
+
65
+ describe 'related models' do
66
+ it "should no longer show up in the relationship to the owner" do
67
+ @luke.androids.size.should == 2
68
+ @r2d2.destroy
69
+ @luke.androids.size.should == 1
70
+ end
71
+
72
+ it "should soft-delete on dependent destroys" do
73
+ lambda{
74
+ @luke.destroy
75
+ }.should change(Android, :count).from(2).to(0)
76
+ Android.count_with_destroyed.should == 2
77
+ end
78
+
79
+ it "shouldn't have problems with has_many :through relationships that are paranoid" do
80
+ # TODO: this spec can be cleaner and more specific, replace it later
81
+ # Dings use a boolean non-standard is_paranoid field
82
+ # Scratch uses the defaults. Testing both ensures compatibility
83
+ [[:dings, Ding], [:scratches, Scratch]].each do |method, klass|
84
+ @r2d2.dings.should == []
85
+
86
+ dent = Dent.create(:description => 'really terrible', :android_id => @r2d2.id)
87
+ item = klass.create(:description => 'quite nasty', :dent_id => dent.id)
88
+ @r2d2.reload
89
+ @r2d2.send(method).should == [item]
90
+
91
+ dent.destroy
92
+ @r2d2.reload
93
+ @r2d2.send(method).should == []
94
+ end
95
+ end
96
+
97
+ it "shouldn't have problems with has_many :through relationships that are not paranoid" do
98
+ @r2d2.dings.should == []
99
+
100
+ dent = Dent.create(:description => 'really terrible', :android_id => @r2d2.id)
101
+ hole = Hole.new(:description => 'What a big hole', :dent_id => dent.id)
102
+
103
+ @r2d2.reload
104
+ @r2d2.holes.should == [hole]
105
+
106
+ hole.destroy
107
+ @r2d2.reload
108
+ @r2d2.holes.should == []
109
+ end
110
+
111
+ it "should not choke has_and_belongs_to_many relationships" do
112
+ @r2d2.places.should include(@tatooine)
113
+ @tatooine.destroy
114
+ @r2d2.reload
115
+ @r2d2.places.should_not include(@tatooine)
116
+ Place.all_with_destroyed.should include(@tatooine)
117
+ end
118
+ end
119
+ end
120
+
121
+ describe 'finding destroyed models' do
122
+ it "should be able to find destroyed items via #find_with_destroyed" do
123
+ @r2d2.destroy
124
+ Android.find(:first, :conditions => {:name => 'R2D2'}).should be_blank
125
+ Android.first_with_destroyed(:conditions => {:name => 'R2D2'}).should_not be_blank
126
+ end
127
+
128
+ it "should be able to find only destroyed items via #find_destroyed_only" do
129
+ @r2d2.destroy
130
+ Android.all_destroyed_only.size.should == 1
131
+ Android.first_destroyed_only.should == @r2d2
132
+ end
133
+
134
+ it "should not show destroyed models via :include" do
135
+ Person.first(:conditions => {:name => LUKE}, :include => :androids).androids.size.should == 2
136
+ @r2d2.destroy
137
+ person = Person.first(:conditions => {:name => LUKE}, :include => :androids)
138
+ # ensure that we're using the preload and not loading it via a find
139
+ Android.should_not_receive(:find)
140
+ person.androids.size.should == 1
141
+ end
142
+ end
143
+
144
+ describe 'calculations' do
145
+ it "should have a proper count inclusively and exclusively of destroyed items" do
146
+ @r2d2.destroy
147
+ @c3p0.destroy
148
+ Android.count.should == 0
149
+ Android.count_with_destroyed.should == 2
150
+ end
151
+
152
+ it "should respond to various calculations" do
153
+ @r2d2.destroy
154
+ Android.sum('id').should == @c3p0.id
155
+ Android.sum_with_destroyed('id').should == @r2d2.id + @c3p0.id
156
+ Android.average_with_destroyed('id').should == (@r2d2.id + @c3p0.id) / 2.0
157
+ end
158
+ end
159
+
160
+ describe 'deletion' do
161
+ it "should actually remove records on #delete_all" do
162
+ lambda{
163
+ Android.delete_all
164
+ }.should change(Android, :count_with_destroyed).from(2).to(0)
165
+ end
166
+
167
+ it "should actually remove records on #delete" do
168
+ lambda{
169
+ Android.first.delete
170
+ }.should change(Android, :count_with_destroyed).from(2).to(1)
171
+ end
172
+ end
173
+
174
+ describe 'restore' do
175
+ it "should allow restoring soft-deleted items" do
176
+ @r2d2.destroy
177
+ lambda{
178
+ @r2d2.restore
179
+ }.should change(Android, :count).from(1).to(2)
180
+ end
181
+
182
+ it "should not hit update/save related callbacks" do
183
+ @r2d2.destroy
184
+
185
+ lambda{
186
+ @r2d2.update_attribute(:name, 'Robocop')
187
+ }.should raise_error
188
+
189
+ lambda{
190
+ @r2d2.restore
191
+ }.should_not raise_error
192
+ end
193
+
194
+ it "should restore dependent models when being restored by default" do
195
+ @r2d2.destroy
196
+ lambda{
197
+ @r2d2.restore
198
+ }.should change(Component, :count).from(0).to(1)
199
+ end
200
+
201
+ it "should provide the option to not restore dependent models" do
202
+ @r2d2.destroy
203
+ lambda{
204
+ @r2d2.restore(:include_destroyed_dependents => false)
205
+ }.should_not change(Component, :count)
206
+ end
207
+
208
+ it "should restore parent and child models specified via :include" do
209
+ sub_component = SubComponent.create(:name => 'part', :component_id => @r2d2.components.first.id)
210
+ @r2d2.destroy
211
+ SubComponent.first(:conditions => {:id => sub_component.id}).should be_nil
212
+ @r2d2.components.first.restore(:include => [:android, :sub_components])
213
+ SubComponent.first(:conditions => {:id => sub_component.id}).should_not be_nil
214
+ Android.find(@r2d2.id).should_not be_nil
215
+ end
216
+ end
217
+
218
+ describe 'validations' do
219
+ it "should not ignore destroyed items in validation checks unless scoped" do
220
+ # Androids are not validates_uniqueness_of scoped
221
+ @r2d2.destroy
222
+ lambda{
223
+ Android.create!(:name => 'R2D2')
224
+ }.should raise_error(ActiveRecord::RecordInvalid)
225
+
226
+ lambda{
227
+ # creating shouldn't raise an error
228
+ another_r2d2 = AndroidWithScopedUniqueness.create!(:name => 'R2D2')
229
+ # neither should destroying the second incarnation since the
230
+ # validates_uniqueness_of is only applied on create
231
+ another_r2d2.destroy
232
+ }.should_not raise_error
233
+ end
234
+ end
235
+
236
+ describe '(parent)_with_destroyed' do
237
+ it "should be able to access destroyed parents" do
238
+ # Memory is has_many with a non-default primary key
239
+ # Sticker is a has_one with a default primary key
240
+ [Memory, Sticker].each do |klass|
241
+ instance = klass.last
242
+ parent = instance.android
243
+ instance.android.destroy
244
+
245
+ # reload so the model doesn't remember the parent
246
+ instance.reload
247
+ instance.android.should == nil
248
+ instance.android_with_destroyed.should == parent
249
+ end
250
+ end
251
+
252
+ it "should be able to access destroyed children" do
253
+ comps = @r2d2.components
254
+ comps.to_s # I have no idea why this makes it pass, but hey, here it is
255
+ @r2d2.components.first.destroy
256
+ @r2d2.components_with_destroyed.should == comps
257
+ end
258
+
259
+ it "should return nil if no destroyed parent exists" do
260
+ sticker = Sticker.new(:name => 'Rainbows')
261
+ # because the default relationship works this way, i.e.
262
+ sticker.android.should == nil
263
+ sticker.android_with_destroyed.should == nil
264
+ end
265
+
266
+ it "should not break method_missing's defined before the is_paranoid call" do
267
+ # we've defined a method_missing on Sticker
268
+ # that changes the sticker name.
269
+ sticker = Sticker.new(:name => "Ponies!")
270
+ lambda{
271
+ sticker.some_crazy_method_that_we_certainly_do_not_respond_to
272
+ }.should change(sticker, :name).to(Sticker::MM_NAME)
273
+ end
274
+ end
275
+
276
+ describe 'alternate fields and field values' do
277
+ it "should properly function for boolean values" do
278
+ # ninjas are invisible by default. not being ninjas, we can only
279
+ # find those that are visible
280
+ ninja = Ninja.create(:name => 'Esteban', :visible => true)
281
+ ninja.vanish # aliased to destroy
282
+ Ninja.first.should be_blank
283
+ Ninja.find_with_destroyed(:first).should == ninja
284
+ Ninja.count.should == 0
285
+
286
+ # we're only interested in pirates who are alive by default
287
+ pirate = Pirate.create(:name => 'Reginald')
288
+ pirate.destroy
289
+ Pirate.first.should be_blank
290
+ Pirate.find_with_destroyed(:first).should == pirate
291
+ Pirate.count.should == 0
292
+
293
+ # we're only interested in pirates who are dead by default.
294
+ # zombie pirates ftw!
295
+ DeadPirate.first.id.should == pirate.id
296
+ lambda{
297
+ DeadPirate.first.destroy
298
+ }.should change(Pirate, :count).from(0).to(1)
299
+ DeadPirate.count.should == 0
300
+ end
301
+ end
302
+
303
+ describe 'after_destroy and before_destroy callbacks' do
304
+ it "should rollback if before_destroy fails" do
305
+ edward = UndestroyablePirate.create(:name => 'Edward')
306
+ lambda{
307
+ edward.destroy
308
+ }.should_not change(UndestroyablePirate, :count)
309
+ end
310
+
311
+ it "should rollback if after_destroy raises an error" do
312
+ raul = RandomPirate.create(:name => 'Raul')
313
+ lambda{
314
+ begin
315
+ raul.destroy
316
+ rescue => ex
317
+ ex.message.should == 'after_destroy works'
318
+ end
319
+ }.should_not change(RandomPirate, :count)
320
+ end
321
+
322
+ it "should handle callbacks normally assuming no failures are encountered" do
323
+ component = Component.first
324
+ lambda{
325
+ component.destroy
326
+ }.should change(component, :name).to(Component::NEW_NAME)
327
+ end
328
+
329
+ end
330
+
331
+ describe "alternate primary key" do
332
+ it "should destroy without problem" do
333
+ uuid = Uuid.create(:name => "foo")
334
+ uuid.destroy.should be_true
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,138 @@
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
+ is_paranoid
8
+ validates_uniqueness_of :name
9
+ has_many :components, :dependent => :destroy
10
+ has_one :sticker
11
+ has_many :memories, :foreign_key => 'parent_id'
12
+ has_many :dents
13
+ has_many :dings, :through => :dents
14
+ has_many :scratches, :through => :dents
15
+ has_many :holes, :through => :dents
16
+ has_and_belongs_to_many :places
17
+
18
+ # this code is to ensure that our destroy and restore methods
19
+ # work without triggering before/after_update callbacks
20
+ before_update :raise_hell
21
+ def raise_hell
22
+ raise "hell"
23
+ end
24
+ end
25
+
26
+ class Dent < ActiveRecord::Base #:nodoc:
27
+ is_paranoid
28
+ belongs_to :android
29
+ has_many :dings
30
+ has_many :scratches
31
+ has_many :holes
32
+ end
33
+
34
+ class Ding < ActiveRecord::Base #:nodoc:
35
+ is_paranoid :field => [:not_deleted, true, false]
36
+ belongs_to :dent
37
+ end
38
+
39
+ class Scratch < ActiveRecord::Base #:nodoc:
40
+ is_paranoid
41
+ belongs_to :dent
42
+ end
43
+
44
+ class Hole < ActiveRecord::Base #:nodoc:
45
+ belongs_to :dent
46
+ end
47
+
48
+ class Component < ActiveRecord::Base #:nodoc:
49
+ is_paranoid
50
+ belongs_to :android, :dependent => :destroy
51
+ has_many :sub_components, :dependent => :destroy
52
+ NEW_NAME = 'Something Else!'
53
+
54
+ after_destroy :change_name
55
+ def change_name
56
+ self.update_attribute(:name, NEW_NAME)
57
+ end
58
+ end
59
+
60
+ class SubComponent < ActiveRecord::Base #:nodoc:
61
+ is_paranoid
62
+ belongs_to :component, :dependent => :destroy
63
+ end
64
+
65
+ class Memory < ActiveRecord::Base #:nodoc:
66
+ is_paranoid
67
+ belongs_to :android, :class_name => "Android", :foreign_key => "parent_id"
68
+ end
69
+
70
+ class Sticker < ActiveRecord::Base #:nodoc:
71
+ MM_NAME = "You've got method_missing"
72
+
73
+ # this simply serves to ensure that we don't break method_missing
74
+ # if it is implemented on a class and called before is_paranoid
75
+ def method_missing name, *args, &block
76
+ self.name = MM_NAME
77
+ end
78
+
79
+ is_paranoid
80
+ belongs_to :android
81
+ end
82
+
83
+ class AndroidWithScopedUniqueness < ActiveRecord::Base #:nodoc:
84
+ set_table_name :androids
85
+ validates_uniqueness_of :name, :scope => :deleted_at
86
+ is_paranoid
87
+ end
88
+
89
+ class Place < ActiveRecord::Base #:nodoc:
90
+ is_paranoid
91
+ has_and_belongs_to_many :androids
92
+ end
93
+
94
+ class AndroidsPlaces < ActiveRecord::Base #:nodoc:
95
+ end
96
+
97
+ class Ninja < ActiveRecord::Base #:nodoc:
98
+ validates_uniqueness_of :name, :scope => :visible
99
+ is_paranoid :field => [:visible, false, true]
100
+
101
+ alias_method :vanish, :destroy
102
+ end
103
+
104
+ class Pirate < ActiveRecord::Base #:nodoc:
105
+ is_paranoid :field => [:alive, false, true]
106
+ end
107
+
108
+ class DeadPirate < ActiveRecord::Base #:nodoc:
109
+ set_table_name :pirates
110
+ is_paranoid :field => [:alive, true, false]
111
+ end
112
+
113
+ class RandomPirate < ActiveRecord::Base #:nodoc:
114
+ set_table_name :pirates
115
+
116
+ def after_destroy
117
+ raise 'after_destroy works'
118
+ end
119
+ end
120
+
121
+ class UndestroyablePirate < ActiveRecord::Base #:nodoc:
122
+ set_table_name :pirates
123
+ is_paranoid :field => [:alive, false, true]
124
+
125
+ def before_destroy
126
+ false
127
+ end
128
+ end
129
+
130
+ class Uuid < ActiveRecord::Base #:nodoc:
131
+ set_primary_key "uuid"
132
+
133
+ def before_create
134
+ self.uuid = "295b3430-85b8-012c-cfe4-002332cf7d5e"
135
+ end
136
+
137
+ is_paranoid
138
+ end
@@ -0,0 +1,91 @@
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 "dents", :force => true do |t|
11
+ t.integer "android_id"
12
+ t.string "description"
13
+ t.datetime "deleted_at"
14
+ end
15
+
16
+ create_table "dings", :force => true do |t|
17
+ t.integer "dent_id"
18
+ t.string "description"
19
+ t.boolean "not_deleted"
20
+ end
21
+
22
+ create_table "scratches", :force => true do |t|
23
+ t.integer "dent_id"
24
+ t.string "description"
25
+ t.datetime "deleted_at"
26
+ end
27
+
28
+ create_table "holes", :force => true do |t|
29
+ t.integer "dent_id"
30
+ t.string "description"
31
+ end
32
+
33
+ create_table "androids_places", :force => true, :id => false do |t|
34
+ t.integer "android_id"
35
+ t.integer "place_id"
36
+ end
37
+
38
+ create_table "places", :force => true do |t|
39
+ t.string "name"
40
+ t.datetime "deleted_at"
41
+ end
42
+
43
+ create_table "people", :force => true do |t|
44
+ t.string "name"
45
+ t.datetime "created_at"
46
+ t.datetime "updated_at"
47
+ end
48
+
49
+ create_table "components", :force => true do |t|
50
+ t.string "name"
51
+ t.integer "android_id"
52
+ t.datetime "deleted_at"
53
+ t.datetime "created_at"
54
+ t.datetime "updated_at"
55
+ end
56
+
57
+ create_table "sub_components", :force => true do |t|
58
+ t.string "name"
59
+ t.integer "component_id"
60
+ t.datetime "deleted_at"
61
+ end
62
+
63
+ create_table "memories", :force => true do |t|
64
+ t.string "name"
65
+ t.integer "parent_id"
66
+ t.datetime "deleted_at"
67
+ end
68
+
69
+ create_table "stickers", :force => true do |t|
70
+ t.string "name"
71
+ t.integer "android_id"
72
+ t.datetime "deleted_at"
73
+ end
74
+
75
+ create_table "ninjas", :force => true do |t|
76
+ t.string "name"
77
+ t.boolean "visible", :default => false
78
+ end
79
+
80
+ create_table "pirates", :force => true do |t|
81
+ t.string "name"
82
+ t.boolean "alive", :default => true
83
+ end
84
+
85
+ create_table "uuids", :id => false, :force => true do |t|
86
+ t.string "uuid", :limit => 36, :primary => true
87
+ t.string "name"
88
+ t.datetime "deleted_at"
89
+ end
90
+
91
+ end
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,14 @@
1
+ require 'rubygems'
2
+ require "#{File.dirname(__FILE__)}/../lib/is_paranoid"
3
+ require 'activerecord'
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: jomz-is_paranoid
3
+ version: !ruby/object:Gem::Version
4
+ hash: 53
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 9
9
+ - 7
10
+ version: 0.9.7
11
+ platform: ruby
12
+ authors:
13
+ - Jeffrey Chupp
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2009-11-23 00:00:00 +01:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: ""
23
+ email: jeff@semanticart.com
24
+ executables: []
25
+
26
+ extensions: []
27
+
28
+ extra_rdoc_files:
29
+ - README.textile
30
+ files:
31
+ - .gitignore
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
+ - --charset=UTF-8
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.
80
+ test_files:
81
+ - spec/is_paranoid_spec.rb
82
+ - spec/models.rb
83
+ - spec/schema.rb
84
+ - spec/spec_helper.rb