acts_as_archival 0.4.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.
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