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 ADDED
@@ -0,0 +1,5 @@
1
+ script: "bundle exec rake"
2
+ rvm:
3
+ - ree
4
+ - 1.9.2
5
+ - 1.9.3
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,3 @@
1
+ module SoftDeletion
2
+ VERSION = '0.1.0'
3
+ 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: []