acts_as_archival 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. data/.gitignore +10 -0
  2. data/.rvmrc +1 -0
  3. data/CHANGELOG.md +5 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +44 -0
  6. data/LICENSE +22 -0
  7. data/README.md +112 -0
  8. data/Rakefile +13 -0
  9. data/acts_as_archival.gemspec +47 -0
  10. data/init.rb +3 -0
  11. data/lib/acts_as_archival.rb +7 -0
  12. data/lib/acts_as_archival/version.rb +3 -0
  13. data/lib/expected_behavior/acts_as_archival.rb +141 -0
  14. data/lib/expected_behavior/acts_as_archival_active_record_methods.rb +20 -0
  15. data/test/ambiguous_table_test.rb +12 -0
  16. data/test/associations_test.rb +102 -0
  17. data/test/basic_test.rb +64 -0
  18. data/test/column_test.rb +15 -0
  19. data/test/database.yml +8 -0
  20. data/test/deep_nesting_test.rb +29 -0
  21. data/test/fixtures/archival.rb +15 -0
  22. data/test/fixtures/archival_grandkid.rb +4 -0
  23. data/test/fixtures/archival_kid.rb +5 -0
  24. data/test/fixtures/exploder.rb +5 -0
  25. data/test/fixtures/independent_archival.rb +9 -0
  26. data/test/fixtures/mass_attribute_protected.rb +4 -0
  27. data/test/fixtures/missing_archive_number.rb +3 -0
  28. data/test/fixtures/missing_archived_at.rb +3 -0
  29. data/test/fixtures/plain.rb +5 -0
  30. data/test/fixtures/poly.rb +9 -0
  31. data/test/fixtures/readonly_when_archived.rb +3 -0
  32. data/test/mass_attribute_test.rb +18 -0
  33. data/test/polymorphic_test.rb +25 -0
  34. data/test/readonly_when_archived_test.rb +22 -0
  35. data/test/responds_test.rb +13 -0
  36. data/test/schema.rb +67 -0
  37. data/test/scope_test.rb +50 -0
  38. data/test/script/db_setup +57 -0
  39. data/test/test_helper.rb +60 -0
  40. data/test/through_association_test.rb +25 -0
  41. data/test/transaction_test.rb +32 -0
  42. metadata +235 -0
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ *~
2
+ *.log
3
+ .bundle
4
+ bin
5
+ vendor/bundle
6
+ test/aaa_test_app/vendor/bundle
7
+ *.bkp
8
+ *.gem
9
+ Gemfile.lock
10
+ pkg/*
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm ruby-1.9.3-p125@aaa --create
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ #0.4.0
2
+ * BUGFIX: when `archive`/`unarchive` fail, they now return false instead of nil
3
+ * Rails 3.2 compatibility -- **Rails 3.0 incompatible due to ARec differences**
4
+ * Gemified!
5
+ * Major test re-organization
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org"
2
+ gemspec
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,3 @@
1
+ # Include hook code here
2
+ ActiveRecord::Base.send :include, ExpectedBehavior::ActsAsArchivalActiveRecordMethods
3
+ ActiveRecord::Base.send :include, ExpectedBehavior::ActsAsArchival
@@ -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,3 @@
1
+ module ActsAsArchival
2
+ VERSION = "0.4.0"
3
+ end
@@ -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