archival_record 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +74 -0
- data/.rubocop_todo.yml +14 -0
- data/.travis.yml +23 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +69 -0
- data/LICENSE +56 -0
- data/README.md +209 -0
- data/Rakefile +16 -0
- data/archival_record.gemspec +51 -0
- data/gemfiles/rails_5.0.gemfile +8 -0
- data/gemfiles/rails_5.1.gemfile +8 -0
- data/gemfiles/rails_5.2.gemfile +8 -0
- data/gemfiles/rails_6.0.gemfile +7 -0
- data/init.rb +3 -0
- data/lib/archival_record.rb +19 -0
- data/lib/archival_record/version.rb +5 -0
- data/lib/archival_record_core/archival_record.rb +164 -0
- data/lib/archival_record_core/archival_record_active_record_methods.rb +45 -0
- data/lib/archival_record_core/association_operation/archive.rb +21 -0
- data/lib/archival_record_core/association_operation/base.rb +54 -0
- data/lib/archival_record_core/association_operation/unarchive.rb +17 -0
- data/script/setup +9 -0
- data/test/ambiguous_table_test.rb +16 -0
- data/test/application_record_test.rb +20 -0
- data/test/associations_test.rb +104 -0
- data/test/basic_test.rb +66 -0
- data/test/callbacks_test.rb +41 -0
- data/test/column_test.rb +17 -0
- data/test/deep_nesting_test.rb +35 -0
- data/test/deprecated_warning_archival_test.rb +11 -0
- data/test/fixtures/another_polys_holder.rb +11 -0
- data/test/fixtures/application_record.rb +5 -0
- data/test/fixtures/application_record_row.rb +8 -0
- data/test/fixtures/archival.rb +19 -0
- data/test/fixtures/archival_grandkid.rb +10 -0
- data/test/fixtures/archival_kid.rb +11 -0
- data/test/fixtures/archival_table_name.rb +10 -0
- data/test/fixtures/callback_archival_4.rb +19 -0
- data/test/fixtures/callback_archival_5.rb +23 -0
- data/test/fixtures/deprecated_warning_archival.rb +9 -0
- data/test/fixtures/exploder.rb +10 -0
- data/test/fixtures/independent_archival.rb +11 -0
- data/test/fixtures/missing_archive_number.rb +7 -0
- data/test/fixtures/missing_archived_at.rb +7 -0
- data/test/fixtures/plain.rb +7 -0
- data/test/fixtures/poly.rb +11 -0
- data/test/fixtures/readonly_when_archived.rb +8 -0
- data/test/polymorphic_test.rb +50 -0
- data/test/readonly_when_archived_test.rb +24 -0
- data/test/relations_test.rb +63 -0
- data/test/responds_test.rb +15 -0
- data/test/schema.rb +96 -0
- data/test/scope_test.rb +92 -0
- data/test/test_helper.rb +91 -0
- data/test/through_association_test.rb +27 -0
- data/test/transaction_test.rb +31 -0
- metadata +254 -0
data/Rakefile
ADDED
@@ -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
|
data/init.rb
ADDED
@@ -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,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
|