phurni-is_less_paranoid 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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,220 @@
1
+ h1. is_less_paranoid
2
+
3
+ This plugin is a fork of the original project "is_paranoid":http://github.com/semanticart/is_paranoid/tree .
4
+
5
+ Please "refer to it":http://github.com/phurni/is_less_paranoid/tree/master/IS_PARANOID_README.textile for the basic what it is.
6
+
7
+
8
+ h3. Less is More
9
+
10
+ Let's take an example to show the added behaviour of the *is_less_paranoid* plugin.
11
+
12
+ Say you have _projects_, _tasks_ and _workers_. A task belongs to a project and is executed by a worker.
13
+ As in the real life, projects may be canceled. So we delete a project (the Project model has been 'paranoid').
14
+ Now when we want to list all tasks made by a worker, the ones that concerned the deleted project will contain
15
+ a _nil_ association for the project.
16
+
17
+ <pre>
18
+ class Project < ActiveRecord::Base
19
+ has_many :tasks
20
+
21
+ is_paranoid
22
+ end
23
+
24
+ class Worker < ActiveRecord::Base
25
+ has_many :tasks
26
+
27
+ is_paranoid
28
+ end
29
+
30
+ class Task < ActiveRecord::Base
31
+ belongs_to :project
32
+ belongs_to :worker
33
+
34
+ is_paranoid
35
+ end
36
+ </pre>
37
+
38
+ <pre>
39
+ house = Project.create(:name => 'Build a house')
40
+ joe = Worker.create(:name => 'Joe')
41
+ Task.create(:name => 'paint walls', :worker => joe, :project => house)
42
+
43
+ house.destroy
44
+ the_task = joe.tasks.first
45
+ the_task.name # <== returns 'paint walls'
46
+ the_task.project # <== returns nil
47
+ </pre>
48
+
49
+ In this scenario I would prefer to see the associated but deleted project. I could even show the destroyed state
50
+ in the view with a specific style.
51
+
52
+ This is exactly the aim of *is_less_paranoid*
53
+
54
+ Note though that when I want to assign a project to a new task I want to list only the undestroyed projects.
55
+ This is done with this:
56
+
57
+ <pre>
58
+ projects = ProjectAlive.all
59
+ </pre>
60
+
61
+ Hey, I didn't setup a ProjectAlive model! Yes, *is_less_paranoid* did it.
62
+
63
+
64
+ h3. How to use it
65
+
66
+ When you add *is_less_paranoid* to your model it will modify it to handle soft deletion (exactly what is_paranoid does)
67
+ but it won't scope your finds with the deleted flag. So any AR find will find normal records and deleted records.
68
+
69
+ This is the reason why associated records are still found even when destroyed.
70
+
71
+ Now when you want to scope your finds with the deleted flag, simply use another model named
72
+ YourModel<strong>Alive</strong> which is automatically created by is_less_paranoid and has exactly the same
73
+ behaviour of your original model (except the scoping).
74
+
75
+ <pre>
76
+ class Project < ActiveRecord::Base
77
+ has_many :tasks
78
+
79
+ is_less_paranoid
80
+ end
81
+
82
+ class Worker < ActiveRecord::Base
83
+ has_many :tasks
84
+
85
+ is_less_paranoid
86
+ end
87
+
88
+ class Task < ActiveRecord::Base
89
+ belongs_to :project
90
+ belongs_to :worker
91
+
92
+ is_less_paranoid
93
+ end
94
+ </pre>
95
+
96
+ <pre>
97
+ house = Project.create(:name => 'Build a house')
98
+ joe = Worker.create(:name => 'Joe')
99
+ Task.create(:name => 'paint walls', :worker => joe, :project => house)
100
+
101
+ house.destroy
102
+ the_task = joe.tasks.first
103
+ the_task.name # => 'paint walls'
104
+ the_task.project # => Project(name:Build a house)
105
+
106
+ ProjectAlive.all # => []
107
+ Project.all # => [Project(name:Build a house)]
108
+ </pre>
109
+
110
+ h4. I want my association to behave like is_paranoid
111
+
112
+ This is why you have a cloned model, simply tell that class name in your association
113
+
114
+ <pre>
115
+ class Project < ActiveRecord::Base
116
+ has_many :tasks, :class_name => 'TaskAlive'
117
+
118
+ end
119
+
120
+ class Task < ActiveRecord::Base
121
+ belongs_to :project
122
+ belongs_to :worker
123
+
124
+ is_less_paranoid
125
+ end
126
+ </pre>
127
+
128
+ <pre>
129
+ house = Project.create(:name => 'Build a house')
130
+ painting = Task.create(:name => 'paint walls', :project => house)
131
+ Task.create(:name => 'clean roof', :project => house)
132
+ house.tasks # => [Task(name:paint walls), Task(name:clean roof)]
133
+ painting.destroy
134
+ house.tasks # => [Task(name:clean roof)]
135
+ </pre>
136
+
137
+ h4. I want my model to scope finds and the clone class not to
138
+
139
+ Yes sometimes the 'Alive' model should be your real model, which is exactly the opposite way of the default
140
+ behaviour for the clone class.
141
+
142
+ No problem, tell it with the option :clone => :with_destroyed. This will create a clone class with 'WithDestroyed'
143
+ as a suffix.
144
+
145
+ <pre>
146
+ class Worker < ActiveRecord::Base
147
+ has_many :tasks
148
+
149
+ is_less_paranoid :clone => :with_destroyed
150
+ end
151
+ </pre>
152
+
153
+ <pre>
154
+ joe = Worker.create(:name => 'Joe')
155
+ Worker.all # => [Worker(name:Joe)]
156
+ WorkerWithDestroyed.all # => [Worker(name:Joe)]
157
+
158
+ joe.destroy
159
+ Worker.all # => []
160
+ WorkerWithDestroyed.all # => [Worker(name:Joe)]
161
+ </pre>
162
+
163
+ Hey, 'WithDestroyed' is ugly. Okay, so choose your prefix or suffix by adding an option
164
+
165
+ <pre>
166
+ class Worker < ActiveRecord::Base
167
+ has_many :tasks
168
+
169
+ is_less_paranoid :clone => :with_destroyed, :suffix => 'WithFired'
170
+ end
171
+ </pre>
172
+
173
+ <pre>
174
+ joe = Worker.create(:name => 'Joe')
175
+ Worker.all # => [Worker(name:Joe)]
176
+ WorkerWithFired.all # => [Worker(name:Joe)]
177
+
178
+ joe.destroy
179
+ Worker.all # => []
180
+ WorkerWithFired.all # => [Worker(name:Joe)]
181
+ </pre>
182
+
183
+ h4. I want bare bone is_paranoid behaviour
184
+
185
+ And you don't want to install the is_paranoid gem, then simply tell that you don't want a clone class
186
+ with the option :clone => false.
187
+
188
+ <pre>
189
+ class Project < ActiveRecord::Base
190
+ has_many :tasks
191
+
192
+ is_less_paranoid :clone => false
193
+ end
194
+ </pre>
195
+
196
+
197
+ h3. Installation
198
+
199
+ You need ActiveRecord 2.3 and you need to properly install this gem.
200
+
201
+ If you're working with Rails, in your environment.rb, add the following to your initializer block.
202
+
203
+ <pre>
204
+ Rails::Initializer.run do |config|
205
+ # ...
206
+ config.gem "phurni-is_less_paranoid", :lib => 'is_less_paranoid'
207
+ end
208
+ </pre>
209
+
210
+
211
+ h3. is_paranoid
212
+
213
+ Every option of the is_paranoid plugin should work with *is_less_paranoid*.
214
+
215
+ Please let me know if you find issues specific to *is_less_paranoid*.
216
+
217
+ h3. Thanks
218
+
219
+ Thanks to *Jeffrey Chupp* for the rewamped acts_as_paranoid aka *is_paranoid*.
220
+
@@ -0,0 +1,24 @@
1
+ require "spec"
2
+ require "spec/rake/spectask"
3
+ require 'lib/is_less_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_less_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. Extended version to allow associated records to still be alive.}
15
+ s.email = %q{pascal_hurni@fastmail.fm}
16
+ s.homepage = %q{http://github.com/phurni/is_less_paranoid/}
17
+ s.description = ""
18
+ s.authors = ["Pascal Hurni", "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,4 @@
1
+ ---
2
+ :minor: 9
3
+ :patch: 0
4
+ :major: 0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'rails/init')
@@ -0,0 +1,304 @@
1
+ require 'activerecord'
2
+
3
+ module IsLessParanoid
4
+ # Call this in your model to enable all the safety-net goodness
5
+ #
6
+ # Example:
7
+ #
8
+ # class Android < ActiveRecord::Base
9
+ # is_less_paranoid
10
+ # end
11
+ #
12
+
13
+ def is_less_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
+ extend ClassMethods
19
+ include InstanceMethods
20
+
21
+ # :clone => :with_destroyed # :clone => :alive # :clone => false
22
+ default_options = {:suffix => opts[:prefix] ? nil : ((opts[:clone] == :with_destroyed) ? 'WithDestroyed' : 'Alive'), :clone => :alive}
23
+ clone_class_and_apply_constraints(opts.reverse_merge(default_options))
24
+ end
25
+
26
+ module CloneClassMethods
27
+ def paranoid_original_class
28
+ @paranoid_original_class
29
+ end
30
+
31
+ def sti_name
32
+ @paranoid_original_class.sti_name
33
+ end
34
+
35
+ def descends_from_active_record?
36
+ @paranoid_original_class.descends_from_active_record?
37
+ end
38
+ end
39
+
40
+ module ClassMethods
41
+ def inherited(subclass) # :nodoc:
42
+ super
43
+ subclass.clone_class_and_apply_constraints(@paranoid_options) unless @cloning || (@paranoid_original_class !=self)
44
+ end
45
+
46
+ def clone_class_and_apply_constraints(options) # :nodoc:
47
+ @paranoid_options = options
48
+ @paranoid_options[:scope_destroyed] = case options[:clone]
49
+ when :with_destroyed then true
50
+ when :alive then false
51
+ when false then true
52
+ else
53
+ raise ArgumentError, "Unknown value #{options[:clone]} for option :clone"
54
+ end
55
+
56
+ if options[:clone]
57
+ table_name # force naming the table (works even if set_table_name has been called)
58
+
59
+ # Create a cloned class in the same namespace with the supplied prefix and suffix
60
+ nesting = self.name.split("::")
61
+ original_class_name = nesting.pop
62
+ clone_class_name = "#{options[:prefix]}#{original_class_name}#{options[:suffix]}"
63
+ namespace = "::#{nesting.join("::")}".constantize
64
+ @cloning = true # FIXME: Not thread safe, should use a Mutex
65
+ namespace.module_eval "class #{clone_class_name} < #{original_class_name}; end"
66
+ @cloning = false
67
+ clone_class = namespace.const_get(clone_class_name)
68
+
69
+ @paranoid_original_class = self
70
+ clone_class.instance_variable_set(:@paranoid_original_class, self)
71
+ clone_class.instance_variable_set(:@paranoid_options, @paranoid_options.reverse_merge(:scope_destroyed => (options[:clone] == :alive)))
72
+
73
+ clone_class.extend CloneClassMethods
74
+ end
75
+
76
+ klass = (options[:clone] == :alive) ? clone_class : self
77
+
78
+ # This is the real magic. All calls made to this model will append
79
+ # the conditions deleted_at => nil (or whatever your destroyed_field
80
+ # and field_not_destroyed are). All exceptions require using
81
+ # exclusive_scope (see self.delete_all, self.count_with_destroyed,
82
+ # and self.find_with_destroyed defined in the module ClassMethods)
83
+ klass.send(:default_scope, :conditions => {destroyed_field => field_not_destroyed})
84
+ end
85
+ protected :inherited, :clone_class_and_apply_constraints
86
+
87
+ def paranoid_original_class
88
+ self
89
+ end
90
+
91
+ # Actually delete the model, bypassing the safety net. Because
92
+ # this method is called internally by Model.delete(id) and on the
93
+ # delete method in each instance, we don't need to specify those
94
+ # methods separately
95
+ def delete_all conditions = nil
96
+ self.with_exclusive_scope { super conditions }
97
+ end
98
+
99
+ # Use update_all with an exclusive scope to restore undo the soft-delete.
100
+ # This bypasses update-related callbacks.
101
+ #
102
+ # By default, restores cascade through associations that are belongs_to
103
+ # :dependent => :destroy and under is_paranoid. You can prevent restoration
104
+ # of associated models by passing :include_destroyed_dependents => false,
105
+ # for example:
106
+ #
107
+ # Android.restore(:include_destroyed_dependents => false)
108
+ #
109
+ # Alternatively you can specify which relationships to restore via :include,
110
+ # for example:
111
+ #
112
+ # Android.restore(:include => [:parts, memories])
113
+ #
114
+ # Please note that specifying :include means you're not using
115
+ # :include_destroyed_dependents by default, though you can explicitly use
116
+ # both if you want all has_* relationships and specific belongs_to
117
+ # relationships, for example
118
+ #
119
+ # Android.restore(:include => [:home, :planet], :include_destroyed_dependents => true)
120
+ def restore(id, options = {})
121
+ options.reverse_merge!({:include_destroyed_dependents => true}) unless options[:include]
122
+ with_exclusive_scope do
123
+ update_all(
124
+ "#{destroyed_field} = #{connection.quote(field_not_destroyed)}",
125
+ "id = #{id}"
126
+ )
127
+ end
128
+
129
+ self.reflect_on_all_associations.each do |association|
130
+ if association.options[:dependent] == :destroy and association.klass.respond_to?(:restore)
131
+ dependent_relationship = association.macro.to_s =~ /^has/
132
+ if should_restore?(association.name, dependent_relationship, options)
133
+ if dependent_relationship
134
+ restore_related(association.klass, association.primary_key_name, id, options)
135
+ else
136
+ restore_related(
137
+ association.klass,
138
+ association.klass.primary_key,
139
+ self.first(id).send(association.primary_key_name),
140
+ options
141
+ )
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # find_with_destroyed and other blah_with_destroyed and
149
+ # blah_destroyed_only methods are defined here
150
+ def method_missing name, *args, &block
151
+ if name.to_s =~ /^(.*)(_destroyed_only|_with_destroyed)$/ and self.respond_to?($1)
152
+ self.extend(Module.new{
153
+ if $2 == '_with_destroyed'
154
+ # Example:
155
+ # def count_with_destroyed(*args)
156
+ # self.with_exclusive_scope{ self.send(:count, *args) }
157
+ # end
158
+ define_method name do |*args|
159
+ self.with_exclusive_scope{ self.send($1, *args) }
160
+ end
161
+ else
162
+
163
+ # Example:
164
+ # def count_destroyed_only(*args)
165
+ # self.with_exclusive_scope do
166
+ # with_scope({:find => { :conditions => ["#{destroyed_field} IS NOT ?", nil] }}) do
167
+ # self.send(:count, *args)
168
+ # end
169
+ # end
170
+ # end
171
+ define_method name do |*args|
172
+ self.with_exclusive_scope do
173
+ with_scope({:find => { :conditions => ["#{self.table_name}.#{destroyed_field} IS NOT ?", field_not_destroyed] }}) do
174
+ self.send($1, *args, &block)
175
+ end
176
+ end
177
+ end
178
+
179
+ end
180
+ })
181
+ self.send(name, *args, &block)
182
+ else
183
+ super(name, *args, &block)
184
+ end
185
+ end
186
+
187
+ # with_exclusive_scope is used internally by ActiveRecord when preloading
188
+ # associations. Unfortunately this is problematic for is_paranoid since we
189
+ # want preloaded is_paranoid items to still be scoped to their deleted conditions.
190
+ # so we override that here.
191
+ def with_exclusive_scope(method_scoping = {}, &block)
192
+ # this is rather hacky, suggestions for improvements appreciated... the idea
193
+ # is that when the caller includes the method preload_associations, we want
194
+ # to apply our is_paranoid conditions
195
+ if @paranoid_options[:scope_destroyed]
196
+ if caller.any?{|c| c =~ /\d+:in `preload_associations'$/}
197
+ method_scoping.deep_merge!(:find => {:conditions => {destroyed_field => field_not_destroyed} })
198
+ end
199
+ end
200
+ super method_scoping, &block
201
+ end
202
+
203
+ protected
204
+
205
+ def should_restore?(association_name, dependent_relationship, options) #:nodoc:
206
+ ([*options[:include]] || []).include?(association_name) or
207
+ (options[:include_destroyed_dependents] and dependent_relationship)
208
+ end
209
+
210
+ def restore_related klass, key_name, id, options #:nodoc:
211
+ klass.find_destroyed_only(:all,
212
+ :conditions => ["#{key_name} = ?", id]
213
+ ).each do |model|
214
+ model.restore(options)
215
+ end
216
+ end
217
+ end
218
+
219
+ module InstanceMethods
220
+ def self.included(base)
221
+ base.class_eval do
222
+ unless method_defined? :method_missing
223
+ def method_missing(meth, *args, &block); super; end
224
+ end
225
+ alias_method :old_method_missing, :method_missing
226
+ alias_method :method_missing, :is_paranoid_method_missing
227
+ end
228
+ end
229
+
230
+ def is_paranoid_method_missing name, *args, &block
231
+ # if we're trying for a _____with_destroyed method
232
+ # and we can respond to the _____ method
233
+ # and we have an association by the name of _____
234
+ if name.to_s =~ /^(.*)(_with_destroyed)$/ and
235
+ self.respond_to?($1) and
236
+ (assoc = self.class.reflect_on_all_associations.detect{|a| a.name.to_s == $1})
237
+
238
+ parent_klass = Object.module_eval("::#{assoc.class_name}", __FILE__, __LINE__)
239
+
240
+ self.class.send(
241
+ :include,
242
+ Module.new{ # Example:
243
+ define_method name do |*args| # def android_with_destroyed
244
+ parent_klass.first_with_destroyed( # Android.first_with_destroyed(
245
+ :conditions => { # :conditions => {
246
+ parent_klass.primary_key => # :id =>
247
+ self.send(assoc.primary_key_name) # self.send(:android_id)
248
+ } # }
249
+ ) # )
250
+ end # end
251
+ }
252
+ )
253
+ self.send(name, *args, &block)
254
+ else
255
+ old_method_missing(name, *args, &block)
256
+ end
257
+ end
258
+
259
+ # Mark the model deleted_at as now.
260
+ def destroy_without_callbacks
261
+ self.class.update_all(
262
+ "#{destroyed_field} = #{self.class.connection.quote(( field_destroyed.respond_to?(:call) ? field_destroyed.call : field_destroyed))}",
263
+ "id = #{self.id}"
264
+ )
265
+ self
266
+ end
267
+
268
+ # Override the default destroy to allow us to flag deleted_at.
269
+ # This preserves the before_destroy and after_destroy callbacks.
270
+ # Because this is also called internally by Model.destroy_all and
271
+ # the Model.destroy(id), we don't need to specify those methods
272
+ # separately.
273
+ def destroy
274
+ return false if callback(:before_destroy) == false
275
+ result = destroy_without_callbacks
276
+ callback(:after_destroy)
277
+ self
278
+ end
279
+
280
+ # Set deleted_at flag on a model to field_not_destroyed, effectively
281
+ # undoing the soft-deletion.
282
+ def restore(options = {})
283
+ self.class.restore(id, options)
284
+ self
285
+ end
286
+
287
+ # Checks whenever this record is destroyed
288
+ # (so that returned records from find with destroyed may be checked against their state)
289
+ def destroyed?
290
+ send(self.class.destroyed_field) != self.class.field_not_destroyed
291
+ end
292
+
293
+ # Records of the original class and the clone class must match
294
+ def ==(comparison_object)
295
+ super ||
296
+ ((self.class.paranoid_original_class == (comparison_object.class.paranoid_original_class rescue nil)) &&
297
+ comparison_object.id == id &&
298
+ !comparison_object.new_record?)
299
+ end
300
+ end
301
+
302
+ end
303
+
304
+ ActiveRecord::Base.send(:extend, IsLessParanoid)