soft_deletion 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +5 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +31 -0
- data/Rakefile +22 -0
- data/Readme.md +48 -0
- data/lib/soft_deletion/dependency.rb +56 -0
- data/lib/soft_deletion/version.rb +3 -0
- data/lib/soft_deletion.rb +67 -0
- data/soft_deletion.gemspec +12 -0
- data/test/soft_deletion_test.rb +171 -0
- metadata +61 -0
data/.travis.yml
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
source :rubygems
|
2
|
+
gemspec
|
3
|
+
|
4
|
+
version = '2.3.14' # hardcoded for now...
|
5
|
+
|
6
|
+
group :development do
|
7
|
+
gem 'activerecord', version
|
8
|
+
gem 'activesupport', version
|
9
|
+
gem 'rake'
|
10
|
+
gem 'shoulda'
|
11
|
+
gem 'mocha'
|
12
|
+
gem 'sqlite3'
|
13
|
+
gem 'mynyml-redgreen'
|
14
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
soft_deletion (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: http://rubygems.org/
|
8
|
+
specs:
|
9
|
+
activerecord (2.3.14)
|
10
|
+
activesupport (= 2.3.14)
|
11
|
+
activesupport (2.3.14)
|
12
|
+
mocha (0.9.12)
|
13
|
+
mynyml-redgreen (0.7.1)
|
14
|
+
term-ansicolor (>= 1.0.4)
|
15
|
+
rake (0.9.2)
|
16
|
+
shoulda (2.10.3)
|
17
|
+
sqlite3 (1.3.6)
|
18
|
+
term-ansicolor (1.0.7)
|
19
|
+
|
20
|
+
PLATFORMS
|
21
|
+
ruby
|
22
|
+
|
23
|
+
DEPENDENCIES
|
24
|
+
activerecord (= 2.3.14)
|
25
|
+
activesupport (= 2.3.14)
|
26
|
+
mocha
|
27
|
+
mynyml-redgreen
|
28
|
+
rake
|
29
|
+
shoulda
|
30
|
+
soft_deletion!
|
31
|
+
sqlite3
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
|
3
|
+
task :default do
|
4
|
+
sh "ruby -Itest test/soft_deletion_test.rb"
|
5
|
+
end
|
6
|
+
|
7
|
+
# extracted from https://github.com/grosser/project_template
|
8
|
+
rule /^version:bump:.*/ do |t|
|
9
|
+
sh "git status | grep 'nothing to commit'" # ensure we are not dirty
|
10
|
+
index = ['major', 'minor','patch'].index(t.name.split(':').last)
|
11
|
+
file = 'lib/soft_deletion/version.rb'
|
12
|
+
|
13
|
+
version_file = File.read(file)
|
14
|
+
old_version, *version_parts = version_file.match(/(\d+)\.(\d+)\.(\d+)/).to_a
|
15
|
+
version_parts[index] = version_parts[index].to_i + 1
|
16
|
+
version_parts[2] = 0 if index < 2 # remove patch for minor
|
17
|
+
version_parts[1] = 0 if index < 1 # remove minor for major
|
18
|
+
new_version = version_parts * '.'
|
19
|
+
File.open(file,'w'){|f| f.write(version_file.sub(old_version, new_version)) }
|
20
|
+
|
21
|
+
sh "bundle && git add #{file} Gemfile.lock && git commit -m 'bump version to #{new_version}'"
|
22
|
+
end
|
data/Readme.md
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
Explicit soft deletion for ActiveRecord via deleted_at and default scope + callbacks.<br/>
|
2
|
+
Not overwriting destroy or delete.
|
3
|
+
|
4
|
+
Install
|
5
|
+
=======
|
6
|
+
gem install soft_deletion
|
7
|
+
Or
|
8
|
+
|
9
|
+
rails plugin install git://github.com/grosser/soft_deletion.git
|
10
|
+
|
11
|
+
|
12
|
+
Usage
|
13
|
+
=====
|
14
|
+
# mix into any model ...
|
15
|
+
class User < ActiveRecord::Base
|
16
|
+
include SoftDeletion
|
17
|
+
has_many :products
|
18
|
+
end
|
19
|
+
|
20
|
+
# soft delete them including all dependencies that are marked as :destroy, :delete_all, :nullify
|
21
|
+
user = User.first
|
22
|
+
user.products.count == 10
|
23
|
+
user.soft_delete!
|
24
|
+
user.deleted? # true
|
25
|
+
|
26
|
+
# use special with_deleted scope to find them ...
|
27
|
+
user.reload # ActiveRecord::RecordNotFound
|
28
|
+
User.with_deleted do
|
29
|
+
user.reload # there it is ...
|
30
|
+
user.products.count == 0
|
31
|
+
end
|
32
|
+
|
33
|
+
# soft undelete them all
|
34
|
+
user.soft_undelete!
|
35
|
+
user.products.count == 10
|
36
|
+
|
37
|
+
TODO
|
38
|
+
====
|
39
|
+
- Rails 3 from with inspiration from https://github.com/JackDanger/permanent_records/blob/master/lib/permanent_records.rb
|
40
|
+
- maybe stuff from https://github.com/smoku/soft_delete
|
41
|
+
|
42
|
+
|
43
|
+
Author
|
44
|
+
======
|
45
|
+
[ZenDesk](http://zendesk.com)<br/>
|
46
|
+
michael@grosser.it<br/>
|
47
|
+
License: MIT<br/>
|
48
|
+
[![Build Status](https://secure.travis-ci.org/grosser/soft_deletion.png)](http://travis-ci.org/grosser/soft_deletion)
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module SoftDeletion
|
2
|
+
class Dependency
|
3
|
+
attr_reader :record, :association_name
|
4
|
+
|
5
|
+
def initialize(record, association_name)
|
6
|
+
@record = record
|
7
|
+
@association_name = association_name
|
8
|
+
end
|
9
|
+
|
10
|
+
def soft_delete!
|
11
|
+
return unless can_soft_delete?
|
12
|
+
|
13
|
+
if nullify?
|
14
|
+
nullify_dependencies
|
15
|
+
else
|
16
|
+
dependencies.each(&:soft_delete!)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def soft_undelete!
|
21
|
+
return unless can_soft_delete?
|
22
|
+
|
23
|
+
klass.with_deleted do
|
24
|
+
dependencies.each(&:soft_undelete!)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
|
30
|
+
def nullify?
|
31
|
+
association.options[:dependent] == :nullify
|
32
|
+
end
|
33
|
+
|
34
|
+
def nullify_dependencies
|
35
|
+
dependencies.each do |dependency|
|
36
|
+
dependency.update_attribute(association.primary_key_name, nil)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def can_soft_delete?
|
41
|
+
klass.instance_methods.map(&:to_sym).include?(:soft_delete!)
|
42
|
+
end
|
43
|
+
|
44
|
+
def klass
|
45
|
+
association.klass
|
46
|
+
end
|
47
|
+
|
48
|
+
def association
|
49
|
+
record.class.reflect_on_association(association_name.to_sym)
|
50
|
+
end
|
51
|
+
|
52
|
+
def dependencies
|
53
|
+
Array.wrap(record.send(association_name.to_sym))
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'soft_deletion/version'
|
3
|
+
require 'soft_deletion/dependency'
|
4
|
+
|
5
|
+
module SoftDeletion
|
6
|
+
def self.included(base)
|
7
|
+
unless base.ancestors.include?(ActiveRecord::Base)
|
8
|
+
raise "You can only include this if #{base} extends ActiveRecord::Base"
|
9
|
+
end
|
10
|
+
base.extend(ClassMethods)
|
11
|
+
base.send(:default_scope, :conditions => base.soft_delete_default_scope_conditions)
|
12
|
+
base.define_callbacks :after_soft_delete
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
def soft_delete_default_scope_conditions
|
17
|
+
{:deleted_at => nil}
|
18
|
+
end
|
19
|
+
|
20
|
+
def soft_delete_dependents
|
21
|
+
self.reflect_on_all_associations.
|
22
|
+
select { |a| [:destroy, :delete_all, :nullify].include?(a.options[:dependent]) }.
|
23
|
+
map(&:name)
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_deleted
|
27
|
+
with_exclusive_scope do
|
28
|
+
yield self
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def deleted?
|
34
|
+
deleted_at.present?
|
35
|
+
end
|
36
|
+
|
37
|
+
def mark_as_deleted
|
38
|
+
self.deleted_at = Time.now
|
39
|
+
end
|
40
|
+
|
41
|
+
def mark_as_undeleted
|
42
|
+
self.deleted_at = nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def soft_delete!
|
46
|
+
self.class.transaction do
|
47
|
+
mark_as_deleted
|
48
|
+
soft_delete_dependencies.each(&:soft_delete!)
|
49
|
+
save!
|
50
|
+
run_callbacks(:after_soft_delete)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def soft_undelete!
|
55
|
+
self.class.transaction do
|
56
|
+
mark_as_undeleted
|
57
|
+
soft_delete_dependencies.each(&:soft_undelete!)
|
58
|
+
save!
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
protected
|
63
|
+
|
64
|
+
def soft_delete_dependencies
|
65
|
+
self.class.soft_delete_dependents.map { |dependent| Dependency.new(self, dependent) }
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
2
|
+
name = "soft_deletion"
|
3
|
+
require "#{name}/version"
|
4
|
+
|
5
|
+
Gem::Specification.new name, SoftDeletion::VERSION do |s|
|
6
|
+
s.summary = "Explicit soft deletion for ActiveRecord via deleted_at and default scope."
|
7
|
+
s.authors = ["ZenDesk"]
|
8
|
+
s.email = "michael@grosser.it"
|
9
|
+
s.homepage = "http://github.com/grosser/#{name}"
|
10
|
+
s.files = `git ls-files`.split("\n")
|
11
|
+
s.license = 'MIT'
|
12
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'soft_deletion'
|
2
|
+
|
3
|
+
require 'active_support/test_case'
|
4
|
+
require 'shoulda'
|
5
|
+
require 'redgreen'
|
6
|
+
|
7
|
+
# connect
|
8
|
+
ActiveRecord::Base.establish_connection(
|
9
|
+
:adapter => "sqlite3",
|
10
|
+
:database => ":memory:"
|
11
|
+
)
|
12
|
+
|
13
|
+
# create tables
|
14
|
+
ActiveRecord::Schema.define(:version => 1) do
|
15
|
+
create_table :forums do |t|
|
16
|
+
t.integer :category_id
|
17
|
+
t.timestamp :deleted_at
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table :categories do |t|
|
21
|
+
t.timestamp :deleted_at
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# setup models
|
26
|
+
class Forum < ActiveRecord::Base
|
27
|
+
include SoftDeletion
|
28
|
+
belongs_to :category
|
29
|
+
end
|
30
|
+
|
31
|
+
class Category < ActiveRecord::Base
|
32
|
+
include SoftDeletion
|
33
|
+
has_many :forums, :dependent => :destroy
|
34
|
+
end
|
35
|
+
|
36
|
+
class NoAssociationCategory < ActiveRecord::Base
|
37
|
+
include SoftDeletion
|
38
|
+
set_table_name 'categories'
|
39
|
+
end
|
40
|
+
|
41
|
+
# Independent association
|
42
|
+
class IDACategory < ActiveRecord::Base
|
43
|
+
include SoftDeletion
|
44
|
+
set_table_name 'categories'
|
45
|
+
has_many :forums, :dependent => :destroy, :foreign_key => :category_id
|
46
|
+
end
|
47
|
+
|
48
|
+
# Nullified dependent association
|
49
|
+
class NDACategory < ActiveRecord::Base
|
50
|
+
include SoftDeletion
|
51
|
+
set_table_name 'categories'
|
52
|
+
has_many :forums, :dependent => :destroy, :foreign_key => :category_id
|
53
|
+
end
|
54
|
+
|
55
|
+
class SoftDeletionTest < ActiveSupport::TestCase
|
56
|
+
def assert_deleted(resource)
|
57
|
+
resource.class.with_deleted do
|
58
|
+
resource.reload
|
59
|
+
assert resource.deleted?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def assert_not_deleted(resource)
|
64
|
+
resource.reload
|
65
|
+
assert !resource.deleted?
|
66
|
+
end
|
67
|
+
|
68
|
+
setup do
|
69
|
+
# clear callbacks
|
70
|
+
Category.class_eval{ @after_soft_delete_callbacks = nil }
|
71
|
+
end
|
72
|
+
|
73
|
+
context ".after_soft_delete" do
|
74
|
+
should "be called after soft-deletion" do
|
75
|
+
Category.after_soft_delete :foo
|
76
|
+
category = Category.create!
|
77
|
+
category.expects(:foo)
|
78
|
+
category.soft_delete!
|
79
|
+
end
|
80
|
+
|
81
|
+
should "not be called after normal destroy" do
|
82
|
+
# TODO clear all callbacks
|
83
|
+
Category.after_soft_delete :foo
|
84
|
+
category = Category.create!
|
85
|
+
category.expects(:foo).never
|
86
|
+
category.destroy
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context 'without dependent associations' do
|
91
|
+
should 'only soft-delete itself' do
|
92
|
+
category = NoAssociationCategory.create!
|
93
|
+
category.soft_delete!
|
94
|
+
assert_deleted category
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'with independent associations' do
|
99
|
+
should 'not delete associations' do
|
100
|
+
category = IDACategory.create!
|
101
|
+
forum = category.forums.create!
|
102
|
+
category.soft_delete!
|
103
|
+
assert_deleted forum
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
context 'with dependent associations' do
|
108
|
+
setup do
|
109
|
+
@category = Category.create!
|
110
|
+
@forum = @category.forums.create!
|
111
|
+
end
|
112
|
+
|
113
|
+
context 'failing to soft delete' do
|
114
|
+
setup do
|
115
|
+
@category.stubs(:valid?).returns(false)
|
116
|
+
assert_raise(ActiveRecord::RecordInvalid) { @category.soft_delete! }
|
117
|
+
end
|
118
|
+
|
119
|
+
should 'not mark itself as deleted' do
|
120
|
+
assert_not_deleted @category
|
121
|
+
end
|
122
|
+
|
123
|
+
should 'not soft delete its dependent associations' do
|
124
|
+
assert_not_deleted @forum
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context 'successfully soft deleted' do
|
129
|
+
setup do
|
130
|
+
@category.soft_delete!
|
131
|
+
end
|
132
|
+
|
133
|
+
should 'mark itself as deleted' do
|
134
|
+
assert_deleted @category
|
135
|
+
end
|
136
|
+
|
137
|
+
should 'soft delete its dependent associations' do
|
138
|
+
assert_deleted @forum
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
context 'being restored from soft deletion' do
|
143
|
+
setup do
|
144
|
+
@category.soft_delete!
|
145
|
+
Category.with_deleted do
|
146
|
+
@category.reload
|
147
|
+
@category.soft_undelete!
|
148
|
+
@category.reload
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
should 'not mark itself as deleted' do
|
153
|
+
assert_not_deleted @category
|
154
|
+
end
|
155
|
+
|
156
|
+
should 'restore its dependent associations' do
|
157
|
+
assert_not_deleted @forum
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
context 'a soft-deleted has-many category that nullifies forum references on delete' do
|
163
|
+
should 'nullify those references' do
|
164
|
+
category = NDACategory.create!
|
165
|
+
forum = category.forums.create!
|
166
|
+
category.soft_delete!
|
167
|
+
assert_deleted forum
|
168
|
+
#assert_nil forum.category_id # TODO
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
metadata
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: soft_deletion
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- ZenDesk
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-05-02 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description:
|
15
|
+
email: michael@grosser.it
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- .travis.yml
|
21
|
+
- Gemfile
|
22
|
+
- Gemfile.lock
|
23
|
+
- Rakefile
|
24
|
+
- Readme.md
|
25
|
+
- lib/soft_deletion.rb
|
26
|
+
- lib/soft_deletion/dependency.rb
|
27
|
+
- lib/soft_deletion/version.rb
|
28
|
+
- soft_deletion.gemspec
|
29
|
+
- test/soft_deletion_test.rb
|
30
|
+
homepage: http://github.com/grosser/soft_deletion
|
31
|
+
licenses:
|
32
|
+
- MIT
|
33
|
+
post_install_message:
|
34
|
+
rdoc_options: []
|
35
|
+
require_paths:
|
36
|
+
- lib
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
38
|
+
none: false
|
39
|
+
requirements:
|
40
|
+
- - ! '>='
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
segments:
|
44
|
+
- 0
|
45
|
+
hash: 1139909322211905394
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
47
|
+
none: false
|
48
|
+
requirements:
|
49
|
+
- - ! '>='
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: '0'
|
52
|
+
segments:
|
53
|
+
- 0
|
54
|
+
hash: 1139909322211905394
|
55
|
+
requirements: []
|
56
|
+
rubyforge_project:
|
57
|
+
rubygems_version: 1.8.24
|
58
|
+
signing_key:
|
59
|
+
specification_version: 3
|
60
|
+
summary: Explicit soft deletion for ActiveRecord via deleted_at and default scope.
|
61
|
+
test_files: []
|