soft_deletion 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/.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
|
+
[](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: []
|