paranoid_create 0.0.10
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/Gemfile +4 -0
- data/MIT-LICENSE +19 -0
- data/README.textile +109 -0
- data/Rakefile +11 -0
- data/init.rb +1 -0
- data/lib/paranoid/base.rb +65 -0
- data/lib/paranoid/join_association.rb +24 -0
- data/lib/paranoid/paranoid_methods.rb +92 -0
- data/lib/paranoid/relation.rb +81 -0
- data/lib/paranoid/version.rb +3 -0
- data/lib/paranoid.rb +5 -0
- data/paranoid.gemspec +26 -0
- data/rdoc/template.rb +613 -0
- data/spec/database.yml +3 -0
- data/spec/models.rb +159 -0
- data/spec/paranoid_spec.rb +298 -0
- data/spec/schema.rb +86 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +27 -0
- metadata +138 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2010 Xspond Inc.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
h1. paranoid
|
2
|
+
|
3
|
+
h3. advice and disclaimer
|
4
|
+
|
5
|
+
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.
|
6
|
+
|
7
|
+
h3. How did I get here?
|
8
|
+
|
9
|
+
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 was 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. Is_paranoid was written for ActiveRecord 2.3 and default_scope. This however became, as the author stated, a mess of hacks to catch all the edge cases. *Paranoid* is an attempt to utilize ActiveRecord::Relation and JoinDependency in ActiveRecord 3 to do all the heavy lifting without using default_scope and with_exclusive_scope.
|
10
|
+
|
11
|
+
h3. How does it work?
|
12
|
+
|
13
|
+
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:
|
14
|
+
|
15
|
+
You need ActiveRecord 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.
|
16
|
+
|
17
|
+
So let's assume we have a model Automobile that has a deleted_at column on the automobiles table.
|
18
|
+
|
19
|
+
If you're working with Rails, in your Gemfile, add the following (you may want to change the version number).
|
20
|
+
|
21
|
+
<pre>
|
22
|
+
gem "paranoid", :require => 'paranoid', :version => ">= 0.1.0"
|
23
|
+
</pre>
|
24
|
+
|
25
|
+
Then in your ActiveRecord model
|
26
|
+
|
27
|
+
<pre>
|
28
|
+
class Automobile < ActiveRecord::Base
|
29
|
+
paranoid
|
30
|
+
end
|
31
|
+
</pre>
|
32
|
+
|
33
|
+
Now our automobiles are soft-deleteable.
|
34
|
+
|
35
|
+
<pre>
|
36
|
+
that_large_automobile = Automobile.create()
|
37
|
+
Automobile.count # => 1
|
38
|
+
|
39
|
+
that_large_automobile.destroy
|
40
|
+
Automobile.count # => 0
|
41
|
+
Automobile.with_destroyed.count # => 1
|
42
|
+
|
43
|
+
# where is that large automobile?
|
44
|
+
that_large_automobile = Automobile.with_destroyed.first
|
45
|
+
that_large_automobile.restore
|
46
|
+
Automobile.count # => 1
|
47
|
+
</pre>
|
48
|
+
|
49
|
+
One thing to note, destroying is always undo-able, but deleting is not. This is a behavior difference between acts_as_paranoid and paranoid and the same as is_paranoid.
|
50
|
+
|
51
|
+
<pre>
|
52
|
+
Automobile.destroy_all
|
53
|
+
Automobile.count # => 0
|
54
|
+
Automobile.with_destroyed.count # => 1
|
55
|
+
|
56
|
+
Automobile.delete_all
|
57
|
+
Automobile.with_destroyed.count # => 0
|
58
|
+
# And you may say to yourself, "My god! What have I done?"
|
59
|
+
</pre>
|
60
|
+
|
61
|
+
You can also lookup only destroyed record with with_destroyed_only.
|
62
|
+
|
63
|
+
<pre>
|
64
|
+
auto1 = Automobile.create()
|
65
|
+
auto2 = Automobile.create()
|
66
|
+
auto2.destroy
|
67
|
+
Automobile.count # => 1
|
68
|
+
Automobile.with_destroyed.count # => 2
|
69
|
+
Automobile.with_destroyed_only.count # => 1
|
70
|
+
Automobile.with_destroyed_only.first # => auto2
|
71
|
+
</pre>
|
72
|
+
|
73
|
+
h3. Specifying alternate rules for what should be considered destroyed
|
74
|
+
|
75
|
+
"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 paranoid. In the 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:
|
76
|
+
|
77
|
+
<pre>
|
78
|
+
class Pirate < ActiveRecord::Base
|
79
|
+
paranoid :field => [:alive, false, true]
|
80
|
+
end
|
81
|
+
|
82
|
+
class DeadPirate < ActiveRecord::Base
|
83
|
+
set_table_name :pirates
|
84
|
+
paranoid :field => [:alive, true, false]
|
85
|
+
end
|
86
|
+
</pre>
|
87
|
+
|
88
|
+
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.
|
89
|
+
|
90
|
+
h3. Note about validates_uniqueness_of:
|
91
|
+
|
92
|
+
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 paranoid and acts_as_paranoid and the same as is_paranoid. You can overcome this by specifying the field name you are using to mark destroyed items as your scope. Example:
|
93
|
+
|
94
|
+
<pre>
|
95
|
+
class Android < ActiveRecord::Base
|
96
|
+
validates_uniqueness_of :name, :scope => :deleted_at
|
97
|
+
paranoid
|
98
|
+
end
|
99
|
+
</pre>
|
100
|
+
|
101
|
+
And now the validates_uniqueness_of will ignore items that are destroyed.
|
102
|
+
|
103
|
+
h3. and you may ask yourself, where does that highway go to?
|
104
|
+
|
105
|
+
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.
|
106
|
+
|
107
|
+
h3. Thanks
|
108
|
+
|
109
|
+
Thanks to Rick Olson for acts_as_paranoid and to Jeffrey Chupp for is_paranoid.
|
data/Rakefile
ADDED
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'paranoid'
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Paranoid
|
2
|
+
module Base
|
3
|
+
# Call this in your model to enable paranoid.
|
4
|
+
#
|
5
|
+
# === Examples
|
6
|
+
#
|
7
|
+
# Post < ActiveRecord::Base
|
8
|
+
# paranoid
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Item < ActiveRecord::Base
|
12
|
+
# paranoid :field => [:available, fales, true]
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# === Options
|
16
|
+
#
|
17
|
+
# [:field]
|
18
|
+
# The field used to recognize a record as destroyed.
|
19
|
+
# Default: :deleted_at
|
20
|
+
# IsParanoid Compatibility: Also accepts an Array of form
|
21
|
+
# [field_name, destroyed_value, not_destroyed_value]
|
22
|
+
# however :destroyed_value and :not_destroyed_value will
|
23
|
+
# be ignored
|
24
|
+
#
|
25
|
+
# [:destroyed_value]
|
26
|
+
# The value to set the paranoid field to on destroy.
|
27
|
+
# Can be either a static value or a Proc which will be
|
28
|
+
# evaluated when destroy is called.
|
29
|
+
# Default: Proc.new{Time.now.utc}
|
30
|
+
#
|
31
|
+
# [:not_destroyed_value]
|
32
|
+
# The value used to recognize a record as not destroyed.
|
33
|
+
# Default: nil
|
34
|
+
def paranoid(opts = {})
|
35
|
+
return if paranoid?
|
36
|
+
@paranoid = true
|
37
|
+
|
38
|
+
opts[:field] ||= [:deleted_at, Proc.new{Time.now.utc}, nil]
|
39
|
+
class_inheritable_accessor :destroyed_field, :field_destroyed, :field_not_destroyed
|
40
|
+
if opts[:field].is_a?(Array)
|
41
|
+
self.destroyed_field, self.field_destroyed, self.field_not_destroyed = opts[:field]
|
42
|
+
else
|
43
|
+
self.destroyed_field = opts.key?(:field) ? opts[:field] : :deleted_at
|
44
|
+
self.field_destroyed = opts.key?(:destroyed_value) ? opts[:destroyed_value] : Proc.new{Time.now.utc}
|
45
|
+
self.field_not_destroyed = opts.key?(:not_destroyed_value) ? opts[:not_destroyed_value] : nil
|
46
|
+
end
|
47
|
+
|
48
|
+
include Paranoid::ParanoidMethods
|
49
|
+
|
50
|
+
class_eval do
|
51
|
+
class << self
|
52
|
+
delegate :with_destroyed, :with_destroyed_only, :to => :scoped
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns true if the model is paranoid and paranoid is enabled
|
58
|
+
def paranoid?
|
59
|
+
@paranoid = (self != ActiveRecord::Base && self.superclass.paranoid?) unless defined?(@paranoid)
|
60
|
+
@paranoid
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
ActiveRecord::Base.class_eval { extend Paranoid::Base }
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Paranoid
|
2
|
+
module JoinAssociation
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
alias_method_chain :association_join, :paranoid
|
7
|
+
end
|
8
|
+
|
9
|
+
# Overrides ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join
|
10
|
+
# adding paranoid conditions when necessary
|
11
|
+
def association_join_with_paranoid
|
12
|
+
return @join if @join
|
13
|
+
result = association_join_without_paranoid
|
14
|
+
if reflection.klass.paranoid?
|
15
|
+
aliased_table = Arel::Table.new(table_name, :as => @aliased_table_name, :engine => arel_engine)
|
16
|
+
pb = ActiveRecord::PredicateBuilder.new(arel_engine)
|
17
|
+
result.concat(pb.build_from_hash(reflection.klass.paranoid_condition, aliased_table))
|
18
|
+
end
|
19
|
+
result
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation.class_eval { include Paranoid::JoinAssociation }
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Paranoid
|
2
|
+
module ParanoidMethods
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
extend ClassMethods
|
7
|
+
alias_method_chain :create_or_update, :paranoid
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# Returns the condition used to scope the return to exclude
|
12
|
+
# soft deleted records
|
13
|
+
def paranoid_condition
|
14
|
+
{destroyed_field => field_not_destroyed}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns the condition used to scope the return to contain
|
18
|
+
# only soft deleted records
|
19
|
+
def paranoid_only_condition
|
20
|
+
val = field_not_destroyed.respond_to?(:call) ? field_not_destroyed.call : field_not_destroyed
|
21
|
+
column_sql = self.sanitize_sql_for_assignment(destroyed_field)
|
22
|
+
case val
|
23
|
+
when nil then "#{column_sql} IS NOT NULL"
|
24
|
+
else ["#{column_sql} != ?", val]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Temporarily disables paranoid on the model
|
29
|
+
def disable_paranoid
|
30
|
+
if block_given?
|
31
|
+
@paranoid = false
|
32
|
+
yield
|
33
|
+
else
|
34
|
+
raise 'Only block form is supported'
|
35
|
+
end
|
36
|
+
ensure
|
37
|
+
@paranoid = true
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Restores the record
|
42
|
+
def restore
|
43
|
+
set_destroyed(field_not_destroyed.respond_to?(:call) ? field_not_destroyed.call : field_not_destroyed)
|
44
|
+
|
45
|
+
self.class.reflect_on_all_associations.each do |association|
|
46
|
+
if association.options[:dependent] == :destroy && association.klass.paranoid?
|
47
|
+
restore_related(association.klass, association.primary_key_name, association.options[:primary_key] || 'id', association.options) if association.macro.to_s =~ /^has/
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
@destroyed = false
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
# Override the default destroy to allow us to soft delete records.
|
56
|
+
# This preserves the before_destroy and after_destroy callbacks.
|
57
|
+
# Because this is also called internally by Model.destroy_all and
|
58
|
+
# the Model.destroy(id), we don't need to specify those methods
|
59
|
+
# separately.
|
60
|
+
def destroy
|
61
|
+
_run_destroy_callbacks do
|
62
|
+
set_destroyed(field_destroyed.respond_to?(:call) ? field_destroyed.call : field_destroyed)
|
63
|
+
@destroyed = true
|
64
|
+
end
|
65
|
+
|
66
|
+
self
|
67
|
+
end
|
68
|
+
|
69
|
+
protected
|
70
|
+
|
71
|
+
# Overrides ActiveRecord::Base#create_or_update
|
72
|
+
# to disable paranoid during the create and update operations
|
73
|
+
def create_or_update_with_paranoid
|
74
|
+
self.class.disable_paranoid { create_or_update_without_paranoid }
|
75
|
+
end
|
76
|
+
|
77
|
+
# Set the value for the destroyed field.
|
78
|
+
def set_destroyed(val)
|
79
|
+
self[destroyed_field] = val
|
80
|
+
updates = Arel::Nodes::SqlLiteral.new(self.class.send(:sanitize_sql_for_assignment, {destroyed_field => val}))
|
81
|
+
self.class.unscoped.with_destroyed.where(self.class.arel_table[self.class.primary_key].eq(id)).arel.update(updates)
|
82
|
+
@destroyed = true
|
83
|
+
end
|
84
|
+
|
85
|
+
# Restores related records
|
86
|
+
def restore_related(klass, key_name, id_name, options)
|
87
|
+
klass.unscoped.with_destroyed_only.where(klass.arel_table[key_name].eq(send(id_name))).each do |model|
|
88
|
+
model.restore
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module Paranoid
|
2
|
+
module Relation
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
alias_method_chain :arel, :paranoid
|
7
|
+
alias_method_chain :delete_all, :paranoid
|
8
|
+
alias_method_chain :except, :paranoid
|
9
|
+
alias_method_chain :only, :paranoid
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns true if the relation should be scoped to
|
13
|
+
# exclude soft deleted records
|
14
|
+
def add_paranoid_condition?
|
15
|
+
@add_paranoid = true unless defined?(@add_paranoid)
|
16
|
+
@klass.paranoid? && @add_paranoid
|
17
|
+
end
|
18
|
+
|
19
|
+
# Overrides ActiveRecord::Relation#arel
|
20
|
+
def arel_with_paranoid
|
21
|
+
if add_paranoid_condition?
|
22
|
+
@arel ||= without_destroyed.arel_without_paranoid
|
23
|
+
else
|
24
|
+
arel_without_paranoid
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Overrides ActiveRecord::Relation#delete_all
|
29
|
+
# forcing delete_all to ignore deleted flag
|
30
|
+
def delete_all_with_paranoid(*args)
|
31
|
+
if add_paranoid_condition?
|
32
|
+
with_destroyed.delete_all_without_paranoid(*args)
|
33
|
+
else
|
34
|
+
delete_all_without_paranoid(*args)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Overrides ActiveRecord::Relation#except
|
39
|
+
def except_with_paranoid(*args)
|
40
|
+
result = except_without_paranoid(*args)
|
41
|
+
result.instance_variable_set(:@add_paranoid, @add_paranoid) if defined?(@add_paranoid)
|
42
|
+
result
|
43
|
+
end
|
44
|
+
|
45
|
+
# Overrides ActiveRecord::Relation#only
|
46
|
+
def only_with_paranoid(*args)
|
47
|
+
result = only_without_paranoid(*args)
|
48
|
+
result.instance_variable_set(:@add_paranoid, @add_paranoid) if defined?(@add_paranoid)
|
49
|
+
result
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns a new relation scoped to include soft deleted records
|
53
|
+
def with_destroyed
|
54
|
+
clone.tap {|relation| relation.skip_paranoid_condition }
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns a new relation scoped to include only deleted records
|
58
|
+
def with_destroyed_only
|
59
|
+
where(@klass.paranoid_only_condition).tap {|relation| relation.skip_paranoid_condition }
|
60
|
+
end
|
61
|
+
|
62
|
+
# Can be used to force the exclusion of soft deleted records down
|
63
|
+
# the chain from a with_destroyed call. *WARNING*: with_destroyed
|
64
|
+
# will do nothing after this has been called! So
|
65
|
+
# Model.without_destroyed.with_destroyed.all will *NOT* return
|
66
|
+
# soft deleted records
|
67
|
+
def without_destroyed
|
68
|
+
where(@klass.paranoid_condition).tap {|relation| relation.skip_paranoid_condition }
|
69
|
+
end
|
70
|
+
|
71
|
+
protected
|
72
|
+
|
73
|
+
# Tell the relation to skip adding the paranoid conditions. DO NOT
|
74
|
+
# call directly. Call with_destroyed.
|
75
|
+
def skip_paranoid_condition
|
76
|
+
@add_paranoid = false
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
ActiveRecord::Relation.class_eval { include Paranoid::Relation }
|
data/lib/paranoid.rb
ADDED
data/paranoid.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# -*- encoding: utf-8 -*-
|
4
|
+
$:.push(File.expand_path('../lib', __FILE__))
|
5
|
+
require('paranoid/version')
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = 'paranoid_create'
|
9
|
+
s.version = Paranoid::VERSION
|
10
|
+
s.platform = Gem::Platform::RUBY
|
11
|
+
s.authors = ['Philipp Ullmann']
|
12
|
+
s.email = 'philipp.ullmann@create.at'
|
13
|
+
s.homepage = 'http://github.com/create-philipp-ullmann/paranoid/'
|
14
|
+
s.summary = 'Enable soft delete of ActiveRecord records. Based off defunct ActsAsParanoid and IsParanoid'
|
15
|
+
|
16
|
+
s.rubyforge_project = 'paranoid_create'
|
17
|
+
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
20
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
21
|
+
s.require_paths = ['lib']
|
22
|
+
|
23
|
+
s.add_development_dependency('rspec', ['>= 2.5.0'])
|
24
|
+
s.add_development_dependency('sqlite3-ruby', ['>= 1.3.3'])
|
25
|
+
s.add_development_dependency('activerecord', ['>= 3.0.5'])
|
26
|
+
end
|