soft_destroyable 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010-11 Michael Kintzer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,18 @@
1
+ SoftDestroyable
2
+ ===============
3
+
4
+ Description goes here.
5
+
6
+ == Note on Patches/Pull Requests
7
+
8
+ * Fork the project.
9
+ * Make your feature addition or bug fix.
10
+ * Add tests for it. This is important so I don't break it in a
11
+ future version unintentionally.
12
+ * Commit, do not mess with rakefile, version, or history.
13
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
14
+ * Send me a pull request. Bonus points for topic branches.
15
+
16
+ == Copyright
17
+
18
+ Copyright (c) 2010-11 Michael Kintzer, released under the MIT license
data/Rakefile ADDED
@@ -0,0 +1,43 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rake/rdoctask'
5
+
6
+ desc 'Default: run unit tests.'
7
+ task :default => :test
8
+
9
+ desc 'Test the soft_destroyable plugin.'
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << 'lib'
12
+ t.libs << 'test'
13
+ t.pattern = 'test/**/*_test.rb'
14
+ t.verbose = true
15
+ end
16
+
17
+ desc 'Generate documentation for the soft_destroyable plugin.'
18
+ Rake::RDocTask.new(:rdoc) do |rdoc|
19
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
20
+ rdoc.rdoc_dir = 'rdoc'
21
+ rdoc.title = "SoftDestroyable #{version}"
22
+ rdoc.options << '--line-numbers' << '--inline-source'
23
+ rdoc.rdoc_files.include('README')
24
+ rdoc.rdoc_files.include('lib/**/*.rb')
25
+ end
26
+
27
+ begin
28
+ require 'jeweler'
29
+ Jeweler::Tasks.new do |gem|
30
+ gem.name = "soft_destroyable"
31
+ gem.summary = "Rails 3 ActiveRecord compatible soft destroy implementation"
32
+ gem.description = "Rails 3 ActiveRecord compatible soft destroy implementation supporting dependent associations"
33
+ gem.email = "rockrep@yahoo.com"
34
+ gem.homepage = "http://github.com/rockrep/soft_destroyable"
35
+ gem.authors = ["Michael Kintzer"]
36
+ gem.add_development_dependency "rails", ">=3.0"
37
+ gem.add_development_dependency "sqlite3", ">=1.3.3"
38
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
39
+ end
40
+ Jeweler::GemcutterTasks.new
41
+ rescue LoadError
42
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
43
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require "soft_destroyable"
data/install.rb ADDED
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,30 @@
1
+ module SoftDestroyable
2
+ # Simply adds a flag to determine whether a model class is soft_destroyable.
3
+ module IsSoftDestroyable
4
+ def self.extended(base) # :nodoc:
5
+ base.class_eval do
6
+ class << self
7
+ alias_method_chain :soft_destroyable, :flag
8
+ end
9
+ end
10
+ end
11
+
12
+ # Overrides the +soft_destroyable+ method to first define the +soft_destroyable?+ class method before
13
+ # deferring to the original +soft_destroyable+.
14
+ def soft_destroyable_with_flag(*args)
15
+ soft_destroyable_without_flag(*args)
16
+
17
+ class << self
18
+ def soft_destroyable?
19
+ true
20
+ end
21
+ end
22
+ end
23
+
24
+ # For all ActiveRecord::Base models that do not call the +soft_destroyable+ method, the +soft_destroyable?+
25
+ # method will return false.
26
+ def soft_destroyable?
27
+ false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ module SoftDestroyable
2
+ module TableDefinition
3
+
4
+ # provide a migration short-cut for defining the required soft-destroyable columns
5
+ # can be used inside of a create_table or change_table (to add the columns)
6
+ #
7
+ # If you want to index on either of these fields, you need to handle that separately
8
+ def soft_destroyable
9
+ column "deleted", :boolean, :default => false
10
+ column "deleted_at", :datetime
11
+ end
12
+
13
+ end
14
+ end
15
+
@@ -0,0 +1,299 @@
1
+ require "#{File.dirname(__FILE__)}/soft_destroyable/table_definition"
2
+ require "#{File.dirname(__FILE__)}/soft_destroyable/is_soft_destroyable"
3
+
4
+ # Allows one to annotate an ActiveRecord module as being soft_destroyable.
5
+ #
6
+ # This changes the behavior of the +destroy+ method to being a soft-destroy, which
7
+ # will set the +deleted_at+ attribute to <tt>Time.now</tt>, and the +deleted+ attribute to <tt>true</tt>
8
+ # It exposes the +revive+ method to reverse the effects of +destroy+.
9
+ # It also exposes the +destroy!+ method which can be used to <b>really</b> destroy an object and it's associations.
10
+ #
11
+ # Standard ActiveRecord destroy callbacks are _not_ called, however you can override +before_soft_destroy+, +after_soft_destroy+,
12
+ # and +before_destroy!+ on your soft_destroyable models.
13
+ #
14
+ # Standard ActiveRecord dependent options :destroy, :restrict, :nullify, :delete_all, and :delete are supported.
15
+ # +revive+ will _not_ undo the effects of +nullify+, +delete_all+, and +delete+. +restrict+ is _not_ effected by the
16
+ # +deleted?+ state. In other words, deleted child models will still restrict destroying the parent.
17
+ #
18
+ # The +delete+ operation is _not_ modified by this module.
19
+ #
20
+ # The operations: +destroy+, +destroy!+, and +revive+ are automatically delegated to the dependent association records.
21
+ # in a single transaction.
22
+ #
23
+ # Examples:
24
+ # class Parent
25
+ # has_many :children, :dependent => :restrict
26
+ # has_many :animals, :dependent => :nullify
27
+ # soft_destroyable
28
+ #
29
+ #
30
+ # Author: Michael Kintzer
31
+ #
32
+
33
+ module SoftDestroyable
34
+
35
+ def self.included(base)
36
+ base.class_eval do
37
+ extend ClassMethods
38
+ extend IsSoftDestroyable
39
+ end
40
+ end
41
+
42
+ module ClassMethods
43
+
44
+ def soft_destroyable(options = {})
45
+ return if soft_destroyable?
46
+
47
+ scope :not_deleted, where(:deleted => false)
48
+ scope :deleted, where(:deleted => true)
49
+
50
+ include InstanceMethods
51
+ extend SingletonMethods
52
+ end
53
+ end
54
+
55
+ module SingletonMethods
56
+
57
+ # returns an array of association symbols that must be managed by soft_destroyable on
58
+ # destroy and destroy!
59
+ def soft_dependencies
60
+ has_many_dependencies + has_one_dependencies
61
+ end
62
+
63
+ def restrict_dependencies
64
+ with_restrict_option(:has_many).map(&:name) + with_restrict_option(:has_one).map(&:name)
65
+ end
66
+
67
+ private
68
+
69
+ def non_through_dependent_associations(type)
70
+ reflect_on_all_associations(type).reject { |k, v|
71
+ k.class == ActiveRecord::Reflection::ThroughReflection }.reject { |k, v| k.options[:dependent].nil? }
72
+ end
73
+
74
+ def has_many_dependencies
75
+ non_through_dependent_associations(:has_many).map(&:name)
76
+ end
77
+
78
+ def has_one_dependencies
79
+ non_through_dependent_associations(:has_one).map(&:name)
80
+ end
81
+
82
+ def with_restrict_option(type)
83
+ non_through_dependent_associations(type).reject { |k, v| k.options[:dependent] != :restrict }
84
+ end
85
+
86
+ end
87
+
88
+ module InstanceMethods
89
+
90
+ # overrides the normal ActiveRecord::Transactions#destroy.
91
+ # can be recovered with +revive+
92
+ def destroy
93
+ before_soft_destroy
94
+ result = soft_destroy
95
+ after_soft_destroy
96
+ result
97
+ end
98
+
99
+ # not a recoverable operation
100
+ def destroy!
101
+ transaction do
102
+ before_destroy!
103
+ cascade_destroy!
104
+ delete
105
+ end
106
+ end
107
+
108
+ # un-does the effect of +destroy+. Does not undo nullify on dependents
109
+ def revive
110
+ transaction do
111
+ cascade_revive
112
+ update_attributes(:deleted_at => nil, :deleted => false)
113
+ end
114
+ end
115
+
116
+ def soft_dependencies
117
+ self.class.soft_dependencies
118
+ end
119
+
120
+ def restrict_dependencies
121
+ self.class.restrict_dependencies
122
+ end
123
+
124
+ # override
125
+ def before_soft_destroy
126
+ # empty
127
+ end
128
+
129
+ # override
130
+ def after_soft_destroy
131
+ # empty
132
+ end
133
+
134
+ # override
135
+ def before_destroy!
136
+ # empty
137
+ end
138
+
139
+ private
140
+
141
+ def non_restrict_dependencies
142
+ soft_dependencies.reject { |assoc_sym| restrict_dependencies.include?(assoc_sym) }
143
+ end
144
+
145
+ def soft_destroy
146
+ transaction do
147
+ cascade_soft_destroy
148
+ update_attributes(:deleted_at => Time.now, :deleted => true)
149
+ end
150
+ end
151
+
152
+ def cascade_soft_destroy
153
+ cascade_to_soft_dependents { |assoc_obj|
154
+ if assoc_obj.respond_to?(:destroy) && assoc_obj.respond_to?(:revive)
155
+ wrap_with_callbacks(assoc_obj, "soft_destroy") do
156
+ assoc_obj.destroy
157
+ end
158
+ else
159
+ wrap_with_callbacks(assoc_obj, "soft_destroy") do
160
+ # no-op
161
+ end
162
+ end
163
+ }
164
+ end
165
+
166
+ def cascade_destroy!
167
+ cascade_to_soft_dependents { |assoc_obj|
168
+ # cascade destroy! to soft dependencies objects
169
+ if assoc_obj.respond_to?(:destroy!)
170
+ wrap_with_callbacks(assoc_obj, "destroy!") do
171
+ assoc_obj.destroy!
172
+ end
173
+ else
174
+ wrap_with_callbacks(assoc_obj, "destroy!") do
175
+ assoc_obj.destroy
176
+ end
177
+ end
178
+ }
179
+ end
180
+
181
+ def cascade_revive
182
+ cascade_to_soft_dependents { |assoc_obj|
183
+ assoc_obj.revive if assoc_obj.respond_to?(:revive)
184
+ }
185
+ end
186
+
187
+ def cascade_to_soft_dependents(&block)
188
+ return unless block_given?
189
+
190
+ # fail fast on :dependent => :restrict
191
+ restrict_dependencies.each { |assoc_sym| handle_restrict(assoc_sym) }
192
+
193
+ non_restrict_dependencies.each do |assoc_sym|
194
+ reflection = reflection_for(assoc_sym)
195
+ association = send(reflection.name)
196
+
197
+ case reflection.options[:dependent]
198
+ when :destroy
199
+ handle_destroy(reflection, association, &block)
200
+ when :nullify
201
+ handle_nullify(reflection, association)
202
+ when :delete_all
203
+ handle_delete_all(reflection, association)
204
+ when :delete
205
+ handle_delete(reflection, association)
206
+ else
207
+ end
208
+
209
+ end
210
+ # reload as dependent associations may have updated
211
+ reload if self.id
212
+ end
213
+
214
+ def handle_destroy(reflection, association, &block)
215
+ case reflection.macro
216
+ when :has_many
217
+ association.each { |assoc_obj| yield(assoc_obj) }
218
+ when :has_one
219
+ # handle non-nil has_one
220
+ yield(association) if association
221
+ else
222
+ end
223
+ end
224
+
225
+ def handle_restrict(assoc_sym)
226
+ reflection = reflection_for(assoc_sym)
227
+ association = send(reflection.name)
228
+ case reflection.macro
229
+ when :has_many
230
+ restrict_on_non_empty_has_many(reflection, association)
231
+ when :has_one
232
+ restrict_on_nil_has_one(reflection, association)
233
+ else
234
+ end
235
+ end
236
+
237
+ def handle_nullify(reflection, association)
238
+ return unless association
239
+ case reflection.macro
240
+ when :has_many
241
+ self.class.send(:nullify_has_many_dependencies,
242
+ self,
243
+ reflection.name,
244
+ reflection.klass,
245
+ reflection.primary_key_name,
246
+ reflection.dependent_conditions(self, self.class, nil))
247
+ when :has_one
248
+ association.update_attributes(reflection.primary_key_name => nil)
249
+ else
250
+ end
251
+
252
+ end
253
+
254
+ def handle_delete_all(reflection, association)
255
+ return unless association
256
+ self.class.send(:delete_all_has_many_dependencies,
257
+ self,
258
+ reflection.name,
259
+ reflection.klass,
260
+ reflection.dependent_conditions(self, self.class, nil))
261
+ end
262
+
263
+ def handle_delete(reflection, association)
264
+ return unless association
265
+ association.update_attribute(reflection.primary_key_name, nil)
266
+ end
267
+
268
+ def wrap_with_callbacks(assoc_obj, action)
269
+ return unless block_given?
270
+ assoc_obj.send("before_#{action}".to_sym) if assoc_obj.respond_to?("before_#{action}".to_sym)
271
+ yield
272
+ assoc_obj.send("after_#{action}".to_sym) if assoc_obj.respond_to?("after_#{action}".to_sym)
273
+ end
274
+
275
+ def reflection_for(assoc_sym)
276
+ self.class.reflect_on_association(assoc_sym)
277
+ end
278
+
279
+ def restrict_on_non_empty_has_many(reflection, association)
280
+ return unless association
281
+ raise ActiveRecord::DeleteRestrictionError.new(reflection) if !association.empty?
282
+ end
283
+
284
+ def restrict_on_nil_has_one(reflection, association)
285
+ raise ActiveRecord::DeleteRestrictionError.new(reflection) if !association.nil?
286
+ end
287
+
288
+ end
289
+
290
+ ActiveRecord::Base.send :include, SoftDestroyable
291
+ [ActiveRecord::ConnectionAdapters::TableDefinition, ActiveRecord::ConnectionAdapters::Table].each { |base|
292
+ base.send(:include, SoftDestroyable::TableDefinition)
293
+ }
294
+
295
+ class SoftDestroyError < StandardError
296
+
297
+ end
298
+
299
+ end
@@ -0,0 +1,111 @@
1
+ # Utility module for Specing Database constraints
2
+ #
3
+ # Would like to rewrite these at some point to be more RSpec 'matcherish' so they would read more BDD-like.
4
+ #
5
+ # Author: Michael Kintzer
6
+ # August 31, 2010
7
+
8
+ module SoftDestroySpecHelper
9
+
10
+ # Ensures that the model class is annotated as +soft_destroyable+
11
+ # and that the model behaves appropriately to destroy and revive
12
+ def asserts_soft_destroy?(model_klass, new_record_args={})
13
+ model_klass = model_klass.constantize if model_klass.is_a?(String)
14
+
15
+ model_klass.respond_to?(:not_deleted).should be_true
16
+
17
+ current_count = model_klass.count
18
+ current_deleted_count = model_klass.deleted.count
19
+
20
+ # create a new record
21
+ obj = model_klass.create!(new_record_args)
22
+
23
+ # verify the database migration
24
+ asserts_soft_destroy_migration?(obj)
25
+
26
+ model_klass.count.should == 1 + current_count
27
+
28
+ # verify soft destroy behaves correctly, and the record still exists and is correctly marked
29
+ obj.destroy.should be_true
30
+ obj.reload
31
+ obj.deleted_at.should_not be_nil
32
+ obj.deleted?.should be_true
33
+
34
+ # verify counts are correct
35
+ model_klass.count.should == 1 + current_count
36
+ model_klass.not_deleted.count.should == 0 + current_count
37
+ model_klass.deleted.count.should == 1 + current_deleted_count
38
+
39
+ # verify revive behaves correctly
40
+ obj.revive
41
+ obj.reload
42
+ obj.deleted_at.should be_nil
43
+ obj.deleted?.should be_false
44
+
45
+ # verify counts are correct
46
+ model_klass.count.should == 1 + current_count
47
+ model_klass.not_deleted.count.should == 1 + current_count
48
+ model_klass.deleted.count.should == 0 + current_deleted_count
49
+ end
50
+
51
+ # Ensures that the model class soft destroys the associated dependent association on +destroy+
52
+ # This helper only valid if :dependent => :destroy
53
+ def asserts_soft_destroy_associations?(model_obj, association_symbol, new_association_record)
54
+ # save model_obj in case it hasn't been yet
55
+ model_obj.save
56
+ association_reflection = model_obj.class.reflect_on_association(association_symbol.to_sym)
57
+ association_reflection.options[:dependent].should == :destroy
58
+ assign_association(model_obj, association_reflection, new_association_record)
59
+
60
+ unless new_association_record.respond_to?(:revive)
61
+ # if associated_klass is NOT soft_destroyable, then calling destroy on parent is a NO-OP so associated_klass should
62
+ # NOT receive a destroy call
63
+ new_association_record.expects(:destroy).never
64
+ end
65
+
66
+ model_obj.destroy
67
+
68
+ if new_association_record.respond_to?(:revive)
69
+ # if associated_klass IS soft_destroyable, then the new_association_record should be deleted
70
+ new_association_record.deleted?.should be_true
71
+ end
72
+ end
73
+
74
+ # Ensures that the model class hard destroys the associated dependent association on +destroy!+
75
+ # This helper only valid if :dependent => :destroy
76
+ def asserts_hard_destroy_associations?(model_obj, association_symbol, new_association_record)
77
+ # save model obj in case it hasn't been yet
78
+ model_obj.save
79
+ association_reflection = model_obj.class.reflect_on_association(association_symbol.to_sym)
80
+ association_reflection.options[:dependent].should == :destroy
81
+ assign_association(model_obj, association_reflection, new_association_record)
82
+ association_reflection.klass.where(association_reflection.primary_key_name => model_obj.id).count.should > 0
83
+ model_obj.destroy!
84
+ association_reflection.klass.where(association_reflection.primary_key_name => model_obj.id).count.should == 0
85
+ end
86
+
87
+ private
88
+
89
+ # verifies the table contains the expected soft destroy fields,
90
+ # and they have the appropriate defaults
91
+ def asserts_soft_destroy_migration?(obj)
92
+ obj.respond_to?(:deleted_at).should be_true
93
+ obj.respond_to?(:deleted).should be_true
94
+ obj.deleted_at.should be_nil
95
+ obj.deleted?.should be_false
96
+ end
97
+
98
+ # Assigns the new_association_record to the model_obj
99
+ def assign_association(model_obj, association_reflection, new_association_record)
100
+ case association_reflection.macro
101
+ when :has_many
102
+ model_obj.send(association_reflection.name) << new_association_record
103
+ when :has_one
104
+ model_obj.send("#{association_reflection.name}=", new_association_record)
105
+ else
106
+ raise NotImplementedError.new("Association #{association_reflection.macro} not handled")
107
+ end
108
+ new_association_record.id.should_not be_nil
109
+ end
110
+
111
+ end
@@ -0,0 +1,33 @@
1
+ require "#{File.dirname(__FILE__)}/test_helper"
2
+
3
+ class BasicTest < Test::Unit::TestCase
4
+
5
+ def setup
6
+ @fred = Parent.create!(:name => "fred")
7
+ end
8
+
9
+ def teardown
10
+ Parent.delete_all
11
+ end
12
+
13
+ def test_destroy
14
+ @fred.destroy
15
+ fred = Parent.where(:name => "fred").first
16
+ assert_not_nil fred
17
+ assert_equal fred.deleted, true
18
+ assert_not_nil fred.deleted_at
19
+ end
20
+
21
+ def test_revive
22
+ @fred.destroy
23
+ assert Parent.deleted.include?(@fred)
24
+ @fred.revive
25
+ assert !Parent.deleted.include?(@fred)
26
+ end
27
+
28
+ def test_destroy!
29
+ @fred.destroy!
30
+ assert_nil Parent.where(:name => "fred").first
31
+ end
32
+
33
+ end
@@ -0,0 +1,52 @@
1
+ require "#{File.dirname(__FILE__)}/test_helper"
2
+
3
+
4
+ class CallbackTest < Test::Unit::TestCase
5
+
6
+ def setup
7
+ @fred = CallbackParent.create!(:name => "fred")
8
+ end
9
+
10
+ def teardown
11
+ CallbackChild.delete_all
12
+ SoftCallbackChild.delete_all
13
+ CallbackParent.delete_all
14
+ end
15
+
16
+ def test_callback_before_soft_destroy_for_soft_children
17
+ @fred.soft_callback_children << pebbles = SoftCallbackChild.new(:name => "pebbles")
18
+ assert_raise PreventSoftDestroyError do
19
+ @fred.destroy
20
+ end
21
+ assert_equal @fred.reload.deleted?, false
22
+ assert_equal pebbles.reload.deleted?, false
23
+ end
24
+
25
+ def test_callback_before_destroy_bang_for_soft_children
26
+ @fred.soft_callback_children << pebbles = SoftCallbackChild.new(:name => "pebbles")
27
+ assert_raise PreventDestroyBangError do
28
+ @fred.destroy!
29
+ end
30
+ assert_equal @fred.reload.deleted?, false
31
+ assert_equal pebbles.reload.deleted?, false
32
+ end
33
+
34
+ def test_callback_before_soft_destroy
35
+ @fred.callback_children << pebbles = CallbackChild.new(:name => "pebbles")
36
+ assert_raise PreventSoftDestroyError do
37
+ @fred.destroy
38
+ end
39
+ assert_equal @fred.reload.deleted?, false
40
+ assert_not_nil pebbles.reload
41
+ end
42
+
43
+ def test_callback_before_destroy!
44
+ @fred.callback_children << pebbles = CallbackChild.new(:name => "pebbles")
45
+ assert_raise PreventDestroyBangError do
46
+ @fred.destroy!
47
+ end
48
+ assert_equal @fred.reload.deleted?, false
49
+ assert_not_nil pebbles.reload
50
+ end
51
+
52
+ end