archival_record 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rubocop.yml +74 -0
  4. data/.rubocop_todo.yml +14 -0
  5. data/.travis.yml +23 -0
  6. data/Appraisals +18 -0
  7. data/CHANGELOG.md +96 -0
  8. data/Gemfile +2 -0
  9. data/Gemfile.lock +69 -0
  10. data/LICENSE +56 -0
  11. data/README.md +209 -0
  12. data/Rakefile +16 -0
  13. data/archival_record.gemspec +51 -0
  14. data/gemfiles/rails_5.0.gemfile +8 -0
  15. data/gemfiles/rails_5.1.gemfile +8 -0
  16. data/gemfiles/rails_5.2.gemfile +8 -0
  17. data/gemfiles/rails_6.0.gemfile +7 -0
  18. data/init.rb +3 -0
  19. data/lib/archival_record.rb +19 -0
  20. data/lib/archival_record/version.rb +5 -0
  21. data/lib/archival_record_core/archival_record.rb +164 -0
  22. data/lib/archival_record_core/archival_record_active_record_methods.rb +45 -0
  23. data/lib/archival_record_core/association_operation/archive.rb +21 -0
  24. data/lib/archival_record_core/association_operation/base.rb +54 -0
  25. data/lib/archival_record_core/association_operation/unarchive.rb +17 -0
  26. data/script/setup +9 -0
  27. data/test/ambiguous_table_test.rb +16 -0
  28. data/test/application_record_test.rb +20 -0
  29. data/test/associations_test.rb +104 -0
  30. data/test/basic_test.rb +66 -0
  31. data/test/callbacks_test.rb +41 -0
  32. data/test/column_test.rb +17 -0
  33. data/test/deep_nesting_test.rb +35 -0
  34. data/test/deprecated_warning_archival_test.rb +11 -0
  35. data/test/fixtures/another_polys_holder.rb +11 -0
  36. data/test/fixtures/application_record.rb +5 -0
  37. data/test/fixtures/application_record_row.rb +8 -0
  38. data/test/fixtures/archival.rb +19 -0
  39. data/test/fixtures/archival_grandkid.rb +10 -0
  40. data/test/fixtures/archival_kid.rb +11 -0
  41. data/test/fixtures/archival_table_name.rb +10 -0
  42. data/test/fixtures/callback_archival_4.rb +19 -0
  43. data/test/fixtures/callback_archival_5.rb +23 -0
  44. data/test/fixtures/deprecated_warning_archival.rb +9 -0
  45. data/test/fixtures/exploder.rb +10 -0
  46. data/test/fixtures/independent_archival.rb +11 -0
  47. data/test/fixtures/missing_archive_number.rb +7 -0
  48. data/test/fixtures/missing_archived_at.rb +7 -0
  49. data/test/fixtures/plain.rb +7 -0
  50. data/test/fixtures/poly.rb +11 -0
  51. data/test/fixtures/readonly_when_archived.rb +8 -0
  52. data/test/polymorphic_test.rb +50 -0
  53. data/test/readonly_when_archived_test.rb +24 -0
  54. data/test/relations_test.rb +63 -0
  55. data/test/responds_test.rb +15 -0
  56. data/test/schema.rb +96 -0
  57. data/test/scope_test.rb +92 -0
  58. data/test/test_helper.rb +91 -0
  59. data/test/through_association_test.rb +27 -0
  60. data/test/transaction_test.rb +31 -0
  61. metadata +254 -0
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require "rake/testtask"
4
+ require "rubocop/rake_task"
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ desc "Default: run all available test suites."
9
+ task default: %I[rubocop test]
10
+
11
+ desc "Test the archival_record gem."
12
+ Rake::TestTask.new(:test) do |t|
13
+ t.libs << "test"
14
+ t.pattern = "test/**/*_test.rb"
15
+ t.verbose = true
16
+ end
@@ -0,0 +1,51 @@
1
+ $LOAD_PATH.push File.expand_path("lib", __dir__)
2
+
3
+ require "archival_record/version"
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.name = "archival_record"
7
+ gem.summary = "Atomic archiving/unarchiving for ActiveRecord"
8
+ gem.version = ArchivalRecord::VERSION
9
+ gem.authors = ["Joel Meador",
10
+ "Michael Kuehl",
11
+ "Matthew Gordon",
12
+ "Vojtech Salbaba",
13
+ "David Jones",
14
+ "Dave Woodward",
15
+ "Miles Sterrett",
16
+ "James Hill",
17
+ "Maarten Claes",
18
+ "Anthony Panozzo",
19
+ "Aaron Milam",
20
+ "Anton Rieder",
21
+ "Josh Menden",
22
+ "Sergey Gnuskov",
23
+ "Elijah Miller"]
24
+ gem.email = ["joel.meador+archival_record@gmail.com"]
25
+ gem.homepage = "http://github.com/janxious/archival_record"
26
+
27
+ gem.files = `git ls-files`.split("\n")
28
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
29
+ gem.require_paths = ["lib"]
30
+ gem.required_ruby_version = ">= 2.4"
31
+
32
+ gem.add_dependency "activerecord", ">= 5.0"
33
+
34
+ gem.add_development_dependency "appraisal"
35
+ gem.add_development_dependency "database_cleaner"
36
+ gem.add_development_dependency "rake"
37
+ gem.add_development_dependency "rr"
38
+ gem.add_development_dependency "rubocop", "~> 0.82.0"
39
+ gem.add_development_dependency "sqlite3"
40
+
41
+ gem.description =
42
+ <<~DESCRIPTION
43
+ *Atomic archiving/unarchiving for ActiveRecord*
44
+
45
+ acts_as_paranoid and similar plugins/gems work on a record-by-record basis and make it difficult to restore records atomically (or archive them, for that matter).
46
+
47
+ Because ArchivalRecord's #archive! and #unarchive! methods are in transactions, and every archival record involved gets the same archive number upon archiving, you can easily restore or remove an entire set of records without having to worry about partial deletion or restoration.
48
+
49
+ Additionally, other plugins generally change how destroy/delete work. ArchivalRecord does not, and thus one can destroy records like normal.
50
+ DESCRIPTION
51
+ end
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.0.0"
6
+ gem "sqlite3", "~> 1.3.13"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.1.0"
6
+ gem "sqlite3", "~> 1.4.1"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.2.0"
6
+ gem "sqlite3", "~> 1.4.1"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 6.0.0"
6
+
7
+ gemspec path: "../"
data/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ # Include hook code here
2
+ ActiveRecord::Base.send :include, ArchivalRecordCore::ArchivalRecordActiveRecordMethods
3
+ ActiveRecord::Base.send :include, ArchivalRecordCore::ArchivalRecord
@@ -0,0 +1,19 @@
1
+ require "archival_record/version"
2
+
3
+ require "archival_record_core/association_operation/base"
4
+ require "archival_record_core/association_operation/archive"
5
+ require "archival_record_core/association_operation/unarchive"
6
+
7
+ require "archival_record_core/archival_record"
8
+ require "archival_record_core/archival_record_active_record_methods"
9
+
10
+ # This assumes a fully Rails 5 compatible set of ActiveRecord models
11
+ if defined?(ApplicationRecord)
12
+ ApplicationRecord.send :include, ArchivalRecordCore::ArchivalRecord
13
+ ApplicationRecord.send :include, ArchivalRecordCore::ArchivalRecordActiveRecordMethods
14
+ else
15
+ ActiveRecord::Base.send :include, ArchivalRecordCore::ArchivalRecord
16
+ ActiveRecord::Base.send :include, ArchivalRecordCore::ArchivalRecordActiveRecordMethods
17
+ end
18
+
19
+ ActiveRecord::Relation.send :include, ArchivalRecordCore::ArchivalRecordActiveRecordMethods::ARRelationMethods
@@ -0,0 +1,5 @@
1
+ module ArchivalRecord
2
+
3
+ VERSION = "2.0.0".freeze
4
+
5
+ end
@@ -0,0 +1,164 @@
1
+ module ArchivalRecordCore
2
+ module ArchivalRecord
3
+
4
+ require "digest/md5"
5
+
6
+ unless defined?(MissingArchivalColumnError) == "constant" && MissingArchivalColumnError.class == Class
7
+ MissingArchivalColumnError = Class.new(ActiveRecord::ActiveRecordError)
8
+ end
9
+ unless defined?(CouldNotArchiveError) == "constant" && CouldNotArchiveError.class == Class
10
+ CouldNotArchiveError = Class.new(ActiveRecord::ActiveRecordError)
11
+ end
12
+ unless defined?(CouldNotUnarchiveError) == "constant" && CouldNotUnarchiveError.class == Class
13
+ CouldNotUnarchiveError = Class.new(ActiveRecord::ActiveRecordError)
14
+ end
15
+
16
+ def self.included(base)
17
+ base.extend ActMethods
18
+ end
19
+
20
+ module ActMethods
21
+
22
+ def archival_record(options = {})
23
+ return if included_modules.include?(InstanceMethods)
24
+
25
+ include InstanceMethods
26
+
27
+ setup_validations(options)
28
+
29
+ setup_scopes
30
+
31
+ setup_callbacks
32
+ end
33
+
34
+ # Deprecated: Please use `archival_record` instead
35
+ def acts_as_archival(options = {})
36
+ ActiveSupport::Deprecation.warn("`acts_as_archival` is deprecated. Please use `archival_record` instead.")
37
+ archival_record(options)
38
+ end
39
+
40
+ private def setup_validations(options)
41
+ before_validation :raise_if_not_archival
42
+ validate :readonly_when_archived if options[:readonly_when_archived]
43
+ end
44
+
45
+ private def setup_scopes
46
+ scope :archived, -> { where.not(archived_at: nil).where.not(archive_number: nil) }
47
+ scope :unarchived, -> { where(archived_at: nil, archive_number: nil) }
48
+ scope :archived_from_archive_number, (lambda do |head_archive_number|
49
+ where(["archived_at IS NOT NULL AND archive_number = ?", head_archive_number])
50
+ end)
51
+ end
52
+
53
+ private def setup_callbacks
54
+ callbackable_actions = %w[archive unarchive]
55
+
56
+ setup_activerecord_callbacks(callbackable_actions)
57
+
58
+ define_callback_dsl_methods(callbackable_actions)
59
+ end
60
+
61
+ private def setup_activerecord_callbacks(callbackable_actions)
62
+ define_callbacks(*[callbackable_actions].flatten)
63
+ end
64
+
65
+ private def define_callback_dsl_methods(callbackable_actions)
66
+ callbackable_actions.each do |action|
67
+ %w[before after].each do |callbackable_type|
68
+ define_callback_dsl_method(callbackable_type, action)
69
+ end
70
+ end
71
+ end
72
+
73
+ private def define_callback_dsl_method(callbackable_type, action)
74
+ # rubocop:disable Security/Eval
75
+ eval <<-END_CALLBACKS, binding, __FILE__, __LINE__ + 1
76
+ unless defined?(#{callbackable_type}_#{action})
77
+ def #{callbackable_type}_#{action}(*args, &blk)
78
+ set_callback(:#{action}, :#{callbackable_type}, *args, &blk)
79
+ end
80
+ end
81
+ END_CALLBACKS
82
+ # rubocop:enable Security/Eval
83
+ end
84
+
85
+ end
86
+
87
+ module InstanceMethods
88
+
89
+ def readonly_when_archived
90
+ readonly_attributes_changed = archived? && changed? && !archived_at_changed? && !archive_number_changed?
91
+ return unless readonly_attributes_changed
92
+
93
+ errors.add(:base, "Cannot modify an archived record.")
94
+ end
95
+
96
+ def raise_if_not_archival
97
+ missing_columns = []
98
+ missing_columns << "archive_number" unless respond_to?(:archive_number)
99
+ missing_columns << "archived_at" unless respond_to?(:archived_at)
100
+ return if missing_columns.blank?
101
+
102
+ raise MissingArchivalColumnError.new("Add '#{missing_columns.join "', '"}' column(s) to '#{self.class.name}' to make it archival")
103
+ end
104
+
105
+ def archived?
106
+ !!(archived_at? && archive_number)
107
+ end
108
+
109
+ def archive!(head_archive_number = nil)
110
+ execute_archival_action(:archive) do
111
+ unless archived?
112
+ head_archive_number ||= Digest::MD5.hexdigest("#{self.class.name}#{id}")
113
+ archive_associations(head_archive_number)
114
+ self.archived_at = DateTime.now
115
+ self.archive_number = head_archive_number
116
+ save!
117
+ end
118
+ end
119
+ end
120
+
121
+ def unarchive!(head_archive_number = nil)
122
+ execute_archival_action(:unarchive) do
123
+ if archived?
124
+ head_archive_number ||= archive_number
125
+ self.archived_at = nil
126
+ self.archive_number = nil
127
+ save!
128
+ unarchive_associations(head_archive_number)
129
+ end
130
+ end
131
+ end
132
+
133
+ def archive_associations(head_archive_number)
134
+ AssociationOperation::Archive.new(self, head_archive_number).execute
135
+ end
136
+
137
+ def unarchive_associations(head_archive_number)
138
+ AssociationOperation::Unarchive.new(self, head_archive_number).execute
139
+ end
140
+
141
+ private def execute_archival_action(action)
142
+ self.class.transaction do
143
+ # rubocop: disable Style/RescueStandardError
144
+ begin
145
+ success = run_callbacks(action) { yield }
146
+ return !!success
147
+ rescue => e
148
+ handle_archival_action_exception(e)
149
+ end
150
+ # rubocop: enable Style/RescueStandardError
151
+ end
152
+ false
153
+ end
154
+
155
+ private def handle_archival_action_exception(exception)
156
+ ActiveRecord::Base.logger.try(:debug, exception.message)
157
+ ActiveRecord::Base.logger.try(:debug, exception.backtrace)
158
+ raise ActiveRecord::Rollback
159
+ end
160
+
161
+ end
162
+
163
+ end
164
+ end
@@ -0,0 +1,45 @@
1
+ module ArchivalRecordCore
2
+ module ArchivalRecordActiveRecordMethods
3
+
4
+ def self.included(base)
5
+ base.extend ARClassMethods
6
+ base.send :include, ARInstanceMethods
7
+ end
8
+
9
+ module ARClassMethods
10
+
11
+ def archival?
12
+ included_modules.include?(ArchivalRecordCore::ArchivalRecord::InstanceMethods)
13
+ end
14
+
15
+ end
16
+
17
+ module ARInstanceMethods
18
+
19
+ def archival?
20
+ self.class.archival?
21
+ end
22
+
23
+ end
24
+
25
+ module ARRelationMethods
26
+
27
+ def archive_all!
28
+ error_message = "The #{klass} must implement 'act_on_archivals' in order to call `archive_all!`"
29
+ raise NotImplementedError.new(error_message) unless archival?
30
+
31
+ head_archive_number = Digest::MD5.hexdigest("#{klass}#{Time.now.utc.to_i}")
32
+ each { |record| record.archive!(head_archive_number) }.tap { reset }
33
+ end
34
+
35
+ def unarchive_all!
36
+ error_message = "The #{klass} must implement 'act_on_archivals' in order to call `unarchive_all!`"
37
+ raise NotImplementedError.new(error_message) unless archival?
38
+
39
+ each(&:unarchive!).tap { reset }
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ module ArchivalRecordCore
2
+ module ArchivalRecord
3
+ module AssociationOperation
4
+ class Archive < Base
5
+
6
+ protected
7
+
8
+ def act_on_archivals(archivals)
9
+ archivals.unarchived.find_each do |related_record|
10
+ raise ActiveRecord::Rollback unless related_record.archive!(head_archive_number)
11
+ end
12
+ end
13
+
14
+ def association_conditions_met?(association)
15
+ association.options[:dependent] == :destroy
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ module ArchivalRecordCore
2
+ module ArchivalRecord
3
+ module AssociationOperation
4
+ class Base
5
+
6
+ attr_reader :model, :head_archive_number
7
+
8
+ def initialize(model, head_archive_number)
9
+ @model = model
10
+ @head_archive_number = head_archive_number
11
+ end
12
+
13
+ def execute
14
+ each_archivable_association do |association|
15
+ act_on_association(association) if association_conditions_met? association
16
+ end
17
+ end
18
+
19
+ protected
20
+
21
+ def each_archivable_association
22
+ model.class.reflect_on_all_associations.each do |association|
23
+ yield(association) if archivable_association?(association)
24
+ end
25
+ end
26
+
27
+ def archivable_association?(association)
28
+ association.macro.to_s =~ /^has/ &&
29
+ association.klass.archival? &&
30
+ association.options[:through].nil?
31
+ end
32
+
33
+ def association_conditions_met?(_association)
34
+ true
35
+ end
36
+
37
+ def act_on_association(association)
38
+ key = association.respond_to?(:foreign_key) ? association.foreign_key : association.primary_key_name
39
+ scope_conditions = { key => model.id }
40
+ # polymorphic associations need a type so we don't accidentally act on multiple associated objects
41
+ # that have the same ID
42
+ scope_conditions[association.type] = model.class.base_class.name if association.type
43
+ scope = association.klass.where(scope_conditions)
44
+ act_on_archivals(scope)
45
+ end
46
+
47
+ def act_on_archivals(_scope)
48
+ raise NotImplementedError.new("The #{self.class} hasn't implemented 'act_on_archivals(scope)'")
49
+ end
50
+
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,17 @@
1
+ module ArchivalRecordCore
2
+ module ArchivalRecord
3
+ module AssociationOperation
4
+ class Unarchive < Base
5
+
6
+ protected
7
+
8
+ def act_on_archivals(scope)
9
+ scope.archived.where(archive_number: head_archive_number).find_each do |related_record|
10
+ raise ActiveRecord::Rollback unless related_record.unarchive!(head_archive_number)
11
+ end
12
+ end
13
+
14
+ end
15
+ end
16
+ end
17
+ end