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 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: []