soft_destroyable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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