acts_as_archival 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +10 -0
- data/.rvmrc +1 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +44 -0
- data/LICENSE +22 -0
- data/README.md +112 -0
- data/Rakefile +13 -0
- data/acts_as_archival.gemspec +47 -0
- data/init.rb +3 -0
- data/lib/acts_as_archival.rb +7 -0
- data/lib/acts_as_archival/version.rb +3 -0
- data/lib/expected_behavior/acts_as_archival.rb +141 -0
- data/lib/expected_behavior/acts_as_archival_active_record_methods.rb +20 -0
- data/test/ambiguous_table_test.rb +12 -0
- data/test/associations_test.rb +102 -0
- data/test/basic_test.rb +64 -0
- data/test/column_test.rb +15 -0
- data/test/database.yml +8 -0
- data/test/deep_nesting_test.rb +29 -0
- data/test/fixtures/archival.rb +15 -0
- data/test/fixtures/archival_grandkid.rb +4 -0
- data/test/fixtures/archival_kid.rb +5 -0
- data/test/fixtures/exploder.rb +5 -0
- data/test/fixtures/independent_archival.rb +9 -0
- data/test/fixtures/mass_attribute_protected.rb +4 -0
- data/test/fixtures/missing_archive_number.rb +3 -0
- data/test/fixtures/missing_archived_at.rb +3 -0
- data/test/fixtures/plain.rb +5 -0
- data/test/fixtures/poly.rb +9 -0
- data/test/fixtures/readonly_when_archived.rb +3 -0
- data/test/mass_attribute_test.rb +18 -0
- data/test/polymorphic_test.rb +25 -0
- data/test/readonly_when_archived_test.rb +22 -0
- data/test/responds_test.rb +13 -0
- data/test/schema.rb +67 -0
- data/test/scope_test.rb +50 -0
- data/test/script/db_setup +57 -0
- data/test/test_helper.rb +60 -0
- data/test/through_association_test.rb +25 -0
- data/test/transaction_test.rb +32 -0
- metadata +235 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm ruby-1.9.3-p125@aaa --create
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
acts_as_archival (0.4.0)
|
5
|
+
activerecord
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activemodel (3.2.2)
|
11
|
+
activesupport (= 3.2.2)
|
12
|
+
builder (~> 3.0.0)
|
13
|
+
activerecord (3.2.2)
|
14
|
+
activemodel (= 3.2.2)
|
15
|
+
activesupport (= 3.2.2)
|
16
|
+
arel (~> 3.0.2)
|
17
|
+
tzinfo (~> 0.3.29)
|
18
|
+
activesupport (3.2.2)
|
19
|
+
i18n (~> 0.6)
|
20
|
+
multi_json (~> 1.0)
|
21
|
+
arel (3.0.2)
|
22
|
+
assertions-eb (1.7.3)
|
23
|
+
builder (3.0.0)
|
24
|
+
database_cleaner (0.7.1)
|
25
|
+
highline (1.6.11)
|
26
|
+
i18n (0.6.0)
|
27
|
+
multi_json (1.1.0)
|
28
|
+
mysql2 (0.3.11)
|
29
|
+
rake (0.9.2.2)
|
30
|
+
rr (1.0.4)
|
31
|
+
tzinfo (0.3.31)
|
32
|
+
|
33
|
+
PLATFORMS
|
34
|
+
ruby
|
35
|
+
|
36
|
+
DEPENDENCIES
|
37
|
+
activesupport
|
38
|
+
acts_as_archival!
|
39
|
+
assertions-eb
|
40
|
+
database_cleaner
|
41
|
+
highline
|
42
|
+
mysql2
|
43
|
+
rake
|
44
|
+
rr
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2009-2012 Expected Behavior
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
# ActsAsArchival
|
2
|
+
|
3
|
+
Plugin for atomically archiving model records in activerecord models.
|
4
|
+
|
5
|
+
We had the problem that acts_as_paranoid and similar plugins/gems
|
6
|
+
always work on a record by record basis and made it very difficult to
|
7
|
+
restore records atomically (or archive them, for that matter).
|
8
|
+
|
9
|
+
Because the archive and unarchive methods are in transactions, and
|
10
|
+
every archival record involved gets the same archive number upon
|
11
|
+
archiving, you can easily restore or remove an entire set of records
|
12
|
+
without having to worry about partial deletion or restoration.
|
13
|
+
|
14
|
+
Additionally, other plugins generally screw with how
|
15
|
+
`destroy`/`delete` work. We don't because we actually want to be able
|
16
|
+
to destroy records.
|
17
|
+
|
18
|
+
## Install
|
19
|
+
|
20
|
+
Rails 3:
|
21
|
+
|
22
|
+
`rails plugin install http://github.com/expectedbehavior/acts_as_archival.git`
|
23
|
+
|
24
|
+
Rails 2:
|
25
|
+
|
26
|
+
`script/plugin install http://github.com/expectedbehavior/acts_as_archival.git -r rails2`
|
27
|
+
|
28
|
+
Any models you want to be archival should have the columns
|
29
|
+
`archive_number`(String) and `archived_at` (DateTime).
|
30
|
+
|
31
|
+
i.e. `script/generate migration AddAAAToPost archive_number:string archived_at:datetime`
|
32
|
+
|
33
|
+
Any dependent-destroy objects connected to an AAA model will be
|
34
|
+
archived with its parent.
|
35
|
+
|
36
|
+
## Example
|
37
|
+
|
38
|
+
``` ruby
|
39
|
+
class Hole < ActiveRecord::Base
|
40
|
+
acts_as_archival
|
41
|
+
has_many :moles, :dependent => :destroy
|
42
|
+
end
|
43
|
+
|
44
|
+
class Mole < ActiveRecord::Base
|
45
|
+
acts_as_archival
|
46
|
+
end
|
47
|
+
```
|
48
|
+
|
49
|
+
``` ruby
|
50
|
+
>> Hole.archived.size # => 0
|
51
|
+
>> Hole.is_archival? # => true
|
52
|
+
>> h = Hole.create
|
53
|
+
>> Hole.unarchived.size # => 1
|
54
|
+
>> h.is_archival? # => true
|
55
|
+
>> h.archived? # => false
|
56
|
+
>> h.muskrats.create
|
57
|
+
>> h.archive # archive hole and muskrat
|
58
|
+
>> h.archive_number # => 8c9f03f9d....
|
59
|
+
>> h.muskrats.first.archive_number # => 8c9f03f9d....
|
60
|
+
>> h.archived? # => 8c9f03f9d....
|
61
|
+
>> Hole.archived.size # => 1
|
62
|
+
>> Hole.unarchived.size # => 0
|
63
|
+
>> h.unarchive
|
64
|
+
>> Hole.archived.size # => 0
|
65
|
+
>> Hole.unarchived.size # => 1
|
66
|
+
```
|
67
|
+
|
68
|
+
## Caveats
|
69
|
+
|
70
|
+
1. This will only work on associations that are dependent destroy. It
|
71
|
+
should be trival to change that or make it optional.
|
72
|
+
1. It will only work for Rails 2.2 and up, because we are using
|
73
|
+
`named_scope`/`scope`. You can check out permanent records for a way
|
74
|
+
to conditionally add the functionality to older Rails installations.
|
75
|
+
1. This will only work (well) on databases with transactions (mysql,
|
76
|
+
postgres, etc.).
|
77
|
+
|
78
|
+
## Testing
|
79
|
+
|
80
|
+
Because this plugin makes use of transactions we're testing it on
|
81
|
+
MySQL instead of the more convenient sqlite. Running the tests should
|
82
|
+
be as easy as:
|
83
|
+
|
84
|
+
``` bash
|
85
|
+
bundle
|
86
|
+
test/script/db_setup # makes the databases with the correct permissions (for mySQL)
|
87
|
+
rake
|
88
|
+
```
|
89
|
+
|
90
|
+
## Help Wanted
|
91
|
+
|
92
|
+
It would be cool if someone could check if this thing works on
|
93
|
+
postgres and if not, submit a patch / let us know about it!
|
94
|
+
|
95
|
+
## Thanks
|
96
|
+
|
97
|
+
ActsAsParanoid and PermanentRecords were both inspirations for this
|
98
|
+
http://github.com/technoweenie/acts_as_paranoid
|
99
|
+
http://github.com/fastestforward/permanent_records
|
100
|
+
|
101
|
+
## Contributors
|
102
|
+
|
103
|
+
* Joel Meador
|
104
|
+
* Michael Kuehl
|
105
|
+
* Matthew Gordon
|
106
|
+
* Vojtech Salbaba
|
107
|
+
* David Jones
|
108
|
+
* Dave Woodward
|
109
|
+
|
110
|
+
Thanks, guys!
|
111
|
+
|
112
|
+
*Copyright (c) 2009-2012 Expected Behavior, LLC, released under the MIT license*
|
data/Rakefile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
require "rake/testtask"
|
4
|
+
|
5
|
+
desc "Default: run unit tests."
|
6
|
+
task :default => :test
|
7
|
+
|
8
|
+
desc "Test the acts_as_archival plugin."
|
9
|
+
Rake::TestTask.new(:test) do |t|
|
10
|
+
t.libs << "test"
|
11
|
+
t.pattern = "test/**/*_test.rb"
|
12
|
+
t.verbose = true
|
13
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$LOAD_PATH.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "acts_as_archival/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |gem|
|
6
|
+
gem.name = "acts_as_archival"
|
7
|
+
gem.summary = %q{Atomic archiving/unarchiving for ActiveRecord-based apps}
|
8
|
+
gem.version = ActsAsArchival::VERSION
|
9
|
+
gem.authors = ["Joel Meador",
|
10
|
+
"Michael Kuehl",
|
11
|
+
"Matthew Gordon",
|
12
|
+
"Vojtech Salbaba",
|
13
|
+
"David Jones",
|
14
|
+
"Dave Woodward"]
|
15
|
+
gem.email = ["joel@expectedbehavior.com",
|
16
|
+
"michael@expectedbehavior.com",
|
17
|
+
"matt@expectedbehavior.com"]
|
18
|
+
gem.homepage = "http://expectedbehavior.com"
|
19
|
+
|
20
|
+
gem.files = `git ls-files`.split("\n")
|
21
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
22
|
+
gem.require_paths = ["lib"]
|
23
|
+
|
24
|
+
gem.add_dependency "activerecord"
|
25
|
+
|
26
|
+
gem.add_development_dependency "activesupport"
|
27
|
+
gem.add_development_dependency "assertions-eb"
|
28
|
+
gem.add_development_dependency "rake"
|
29
|
+
gem.add_development_dependency "mysql2"
|
30
|
+
gem.add_development_dependency "highline"
|
31
|
+
gem.add_development_dependency "rr"
|
32
|
+
gem.add_development_dependency "database_cleaner"
|
33
|
+
|
34
|
+
gem.description = <<-END
|
35
|
+
We had the problem that acts_as_paranoid and similar plugins/gems always work on
|
36
|
+
a record by record basis and made it very difficult to restore records
|
37
|
+
atomically (or archive them, for that matter).
|
38
|
+
|
39
|
+
Because the archive and unarchive methods are in transactions, and every
|
40
|
+
archival record involved gets the same archive number upon archiving, you can
|
41
|
+
easily restore or remove an entire set of records without having to worry about
|
42
|
+
partial deletion or restoration.
|
43
|
+
|
44
|
+
Additionally, other plugins generally screw with how destroy/delete work. We
|
45
|
+
don't because we actually want to be able to destroy records.
|
46
|
+
END
|
47
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
require "acts_as_archival/version"
|
2
|
+
|
3
|
+
require "expected_behavior/acts_as_archival"
|
4
|
+
require "expected_behavior/acts_as_archival_active_record_methods"
|
5
|
+
|
6
|
+
ActiveRecord::Base.send :include, ExpectedBehavior::ActsAsArchival
|
7
|
+
ActiveRecord::Base.send :include, ExpectedBehavior::ActsAsArchivalActiveRecordMethods
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module ExpectedBehavior
|
2
|
+
module ActsAsArchival
|
3
|
+
require 'digest/md5'
|
4
|
+
|
5
|
+
ARCHIVED_CONDITIONS = lambda { |zelf| %Q{#{zelf.to_s.tableize}.archived_at IS NOT NULL AND #{zelf.to_s.tableize}.archive_number IS NOT NULL} }
|
6
|
+
UNARCHIVED_CONDITIONS = { :archived_at => nil, :archive_number => nil }
|
7
|
+
|
8
|
+
MissingArchivalColumnError = Class.new(ActiveRecord::ActiveRecordError) unless defined?(MissingArchivalColumnError) == 'constant' && MissingArchivalColumnError.class == Class
|
9
|
+
CouldNotArchiveError = Class.new(ActiveRecord::ActiveRecordError) unless defined?(CouldNotArchiveError) == 'constant' && CouldNotArchiveError.class == Class
|
10
|
+
CouldNotUnarchiveError = Class.new(ActiveRecord::ActiveRecordError) unless defined?(CouldNotUnarchiveError) == 'constant' && CouldNotUnarchiveError.class == Class
|
11
|
+
|
12
|
+
def self.included(base)
|
13
|
+
base.extend ActMethods
|
14
|
+
end
|
15
|
+
|
16
|
+
module ActMethods
|
17
|
+
def acts_as_archival(options = { })
|
18
|
+
unless included_modules.include? InstanceMethods
|
19
|
+
include InstanceMethods
|
20
|
+
|
21
|
+
before_validation :raise_if_not_archival
|
22
|
+
validate :readonly_when_archived if options[:readonly_when_archived]
|
23
|
+
|
24
|
+
scope :archived, :conditions => ARCHIVED_CONDITIONS.call(self)
|
25
|
+
scope :unarchived, :conditions => UNARCHIVED_CONDITIONS
|
26
|
+
scope :archived_from_archive_number, lambda { |head_archive_number| {:conditions => ['archived_at IS NOT NULL AND archive_number = ?', head_archive_number] } }
|
27
|
+
|
28
|
+
callbacks = ['archive','unarchive']
|
29
|
+
define_callbacks *[callbacks, {:terminator => 'result == false'}].flatten
|
30
|
+
callbacks.each do |callback|
|
31
|
+
eval <<-end_callbacks
|
32
|
+
def before_#{callback}(*args, &blk)
|
33
|
+
set_callback(:#{callback}, :before, *args, &blk)
|
34
|
+
end
|
35
|
+
def after_#{callback}(*args, &blk)
|
36
|
+
set_callback(:#{callback}, :after, *args, &blk)
|
37
|
+
end
|
38
|
+
end_callbacks
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
module InstanceMethods
|
46
|
+
|
47
|
+
def readonly_when_archived
|
48
|
+
if self.archived? && self.changed? && !self.archived_at_changed? && !self.archive_number_changed?
|
49
|
+
self.errors.add(:base, "Cannot modifify an archived record.")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def raise_if_not_archival
|
54
|
+
missing_columns = []
|
55
|
+
missing_columns << "archive_number" unless self.respond_to?(:archive_number)
|
56
|
+
missing_columns << "archived_at" unless self.respond_to?(:archived_at)
|
57
|
+
raise MissingArchivalColumnError.new("Add '#{missing_columns.join "', '"}' column(s) to '#{self.class.name}' to make it archival") unless missing_columns.blank?
|
58
|
+
end
|
59
|
+
|
60
|
+
def archived?
|
61
|
+
self.archived_at? && self.archive_number
|
62
|
+
end
|
63
|
+
|
64
|
+
def archive(head_archive_number=nil)
|
65
|
+
self.class.transaction do
|
66
|
+
begin
|
67
|
+
run_callbacks :archive, :before
|
68
|
+
unless self.archived?
|
69
|
+
head_archive_number ||= Digest::MD5.hexdigest("#{self.class.name}#{self.id}")
|
70
|
+
self.archive_associations(head_archive_number)
|
71
|
+
self.archived_at = DateTime.now
|
72
|
+
self.archive_number = head_archive_number
|
73
|
+
self.save!
|
74
|
+
end
|
75
|
+
run_callbacks :archive, :after
|
76
|
+
return true
|
77
|
+
rescue
|
78
|
+
raise ActiveRecord::Rollback
|
79
|
+
end
|
80
|
+
end
|
81
|
+
false
|
82
|
+
end
|
83
|
+
|
84
|
+
def unarchive(head_archive_number=nil)
|
85
|
+
self.class.transaction do
|
86
|
+
begin
|
87
|
+
run_callbacks :unarchive, :before
|
88
|
+
if self.archived?
|
89
|
+
head_archive_number ||= self.archive_number
|
90
|
+
self.archived_at = nil
|
91
|
+
self.archive_number = nil
|
92
|
+
self.save!
|
93
|
+
self.unarchive_associations(head_archive_number)
|
94
|
+
end
|
95
|
+
run_callbacks :unarchive, :after
|
96
|
+
return true
|
97
|
+
rescue
|
98
|
+
raise ActiveRecord::Rollback
|
99
|
+
end
|
100
|
+
end
|
101
|
+
false
|
102
|
+
end
|
103
|
+
|
104
|
+
def archive_associations(head_archive_number)
|
105
|
+
act_only_on_dependent_destroy_associations = Proc.new {|association| association.options[:dependent] == :destroy}
|
106
|
+
act_on_all_archival_associations(head_archive_number, :archive => true, :association_options => act_only_on_dependent_destroy_associations)
|
107
|
+
end
|
108
|
+
|
109
|
+
def unarchive_associations(head_archive_number)
|
110
|
+
act_on_all_archival_associations(head_archive_number, :unarchive => true)
|
111
|
+
end
|
112
|
+
|
113
|
+
def act_on_all_archival_associations(head_archive_number, options={})
|
114
|
+
return if options.length == 0
|
115
|
+
options[:association_options] ||= Proc.new { true }
|
116
|
+
self.class.reflect_on_all_associations.each do |association|
|
117
|
+
if association.macro.to_s =~ /^has/ && association.klass.is_archival? && options[:association_options].call(association) && association.options[:through].nil?
|
118
|
+
act_on_a_related_archival(association.klass, association.foreign_key, id, head_archive_number, options)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def act_on_a_related_archival(klass, key_name, id, head_archive_number, options={})
|
124
|
+
return if options.length == 0 || (!options[:archive] && !options[:unarchive])
|
125
|
+
if options[:archive]
|
126
|
+
klass.unarchived.find(:all, :conditions => ["#{key_name} = ?", id]).each do |related_record|
|
127
|
+
unless related_record.archive(head_archive_number)
|
128
|
+
raise ActiveRecord::Rollback
|
129
|
+
end
|
130
|
+
end
|
131
|
+
else
|
132
|
+
klass.archived.find(:all, :conditions => ["#{key_name} = ? AND archive_number = ?", id, head_archive_number]).each do |related_record|
|
133
|
+
unless related_record.unarchive(head_archive_number)
|
134
|
+
raise ActiveRecord::Rollback
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ExpectedBehavior
|
2
|
+
module ActsAsArchivalActiveRecordMethods
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ARClassMethods
|
5
|
+
base.send :include, ARInstanceMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ARClassMethods
|
9
|
+
def is_archival?
|
10
|
+
self.included_modules.include?(ExpectedBehavior::ActsAsArchival::InstanceMethods)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module ARInstanceMethods
|
15
|
+
def is_archival?
|
16
|
+
self.class.is_archival?
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|