acts_as_revisionable 1.0.6 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +13 -5
- data/RELEASES.txt +7 -0
- data/Rakefile +4 -3
- data/VERSION +1 -1
- data/acts_as_revisionable.gemspec +13 -15
- data/lib/acts_as_revisionable.rb +101 -30
- data/lib/acts_as_revisionable/revision_record.rb +96 -66
- data/spec/acts_as_revisionable_spec.rb +682 -135
- data/spec/revision_record_spec.rb +291 -254
- data/spec/spec_helper.rb +18 -1
- data/spec/version_1_1_upgrade_spec.rb +41 -0
- metadata +33 -19
- data/spec/full_spec.rb +0 -449
data/README.rdoc
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
This gem can handle automatically keeping revisions of a model each time it is updated. It is intended to allow you to keep a history of changes that can be reviewed or restored. This implementation has the advantages that it can track associations with a parent record and that it takes less space in the database to store the revisions.
|
4
4
|
|
5
|
-
To make any ActiveRecord model revisionable, simply declare acts_as_revisionable in the class definition. Revisions are only added when a record is updated, so newly created records don't have revisions. This is intentional to reduce the number of revisions that need to be kept. In many applications, the majority of records are created once and never edited and adding the revision on create ends up at least doubling your storage needs. The attributes of the original record are serialized and compressed for in the revision record to minimize the amount of disk space used. Finally, you can limit the number of associations that are kept at any one time by supplying a
|
5
|
+
To make any ActiveRecord model revisionable, simply declare +acts_as_revisionable+ in the class definition. Revisions are only added when a record is updated, so newly created records don't have revisions. This is intentional to reduce the number of revisions that need to be kept. In many applications, the majority of records are created once and never edited and adding the revision on create ends up at least doubling your storage needs. The attributes of the original record are serialized and compressed for in the revision record to minimize the amount of disk space used. Finally, you can limit the number of associations that are kept at any one time by supplying a <tt>:limit</tt> option to the +acts_as_revisionable+ statement:
|
6
6
|
|
7
7
|
acts_as_revisionable :limit => 25
|
8
8
|
|
@@ -14,7 +14,7 @@ Revisions are accessible on a record via a has_many :revision_records associatio
|
|
14
14
|
|
15
15
|
== Associations
|
16
16
|
|
17
|
-
You can specify associations that you'd like to include in each revision by providing a list of them with the
|
17
|
+
You can specify associations that you'd like to include in each revision by providing a list of them with the <tt>:associations</tt> key to the acts_as_revisionable options hash. You can either provide a symbol with the association name or, if you'd like to include sub-associations, a hash with the association name as the key and the value as a list of sub-associations to include. These are are valid :associations values:
|
18
18
|
|
19
19
|
:associations => :comments # include has_many :comments in the revision
|
20
20
|
:associations => [:comments, :tags] # include both :comments and :tags
|
@@ -24,7 +24,7 @@ You can only revision has_many, has_one, and has_and_belongs_to_many association
|
|
24
24
|
|
25
25
|
== Storing Revisions
|
26
26
|
|
27
|
-
Normally, revisions are only created when an update is done inside of a store_revision block. You can make this behavior automatic on update by specifying
|
27
|
+
Normally, revisions are only created when an update is done inside of a store_revision block. You can make this behavior automatic on update by specifying <tt>:on_update => true</tt> in the +acts_as_revisionable+ call. This can be handy if you have a simple records without associations. If you do have associations in your model, you should not use this feature because you may end up revisioning associations in an indeterminate state. In this case, surround all your update statements with a store_revision block:
|
28
28
|
|
29
29
|
store_revision do
|
30
30
|
model.update_has_many_records(params[:has_many])
|
@@ -49,8 +49,16 @@ To create the table structure for ActsAsRevisionable::RevisionRecord, simply add
|
|
49
49
|
|
50
50
|
ActsAsRevisionable::RevisionRecord.create_table
|
51
51
|
|
52
|
+
Note that the table definition changed from version 1.0.x to 1.1.x. If you originally created the table with version 1.0.x, you'll need to add another migration containing:
|
53
|
+
|
54
|
+
ActsAsRevisionable::RevisionRecord.update_version_1_table
|
55
|
+
|
52
56
|
== Destroying
|
53
57
|
|
54
|
-
By default, the revision history of a record is destroyed along with the record
|
58
|
+
By default, the revision history of a record is destroyed along with the record. If you'd like to keep the history and be able to restore deleted records, then you can specify <tt>:dependent => :keep, :on_destroy => true</tt> in options of the +acts_as_revisionable+ call. This will keep the revision records and also automatically wrap all destroy calls in a +store_revision+ block. It is highly recommended that you turn on these options.
|
59
|
+
|
60
|
+
Destroyed records can be restored by restoring the last revision.
|
55
61
|
|
56
|
-
|
62
|
+
record = Model.find(100)
|
63
|
+
record.destroy
|
64
|
+
restored = Model.restore_last_revision!(100)
|
data/RELEASES.txt
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
1.1.0
|
2
|
+
|
3
|
+
- Add support for compound_primary_keys gem
|
4
|
+
- Incorporate acts_as_trashable functionality
|
5
|
+
- More helper functions for finding revisions from the model
|
6
|
+
- Remove support for ActiveRecord < 2.3.5
|
7
|
+
- Users of earlier versions will need to upgrade the table with a migration
|
data/Rakefile
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'rake'
|
3
|
-
require '
|
3
|
+
require 'rdoc/task'
|
4
4
|
|
5
5
|
desc 'Default: run unit tests.'
|
6
6
|
task :default => :test
|
@@ -33,9 +33,10 @@ begin
|
|
33
33
|
gem.email = "brian@embellishedvisions.com"
|
34
34
|
gem.homepage = "http://github.com/bdurand/acts_as_revisionable"
|
35
35
|
gem.authors = ["Brian Durand"]
|
36
|
-
gem.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc"]
|
36
|
+
gem.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc", "MIT-LICENSE"]
|
37
37
|
|
38
|
-
gem.add_dependency('activerecord', '>= 2.
|
38
|
+
gem.add_dependency('activerecord', '>= 2.3.5')
|
39
|
+
gem.add_development_dependency('composite_primary_keys')
|
39
40
|
gem.add_development_dependency('sqlite3')
|
40
41
|
gem.add_development_dependency('rspec', '>= 2.0.0')
|
41
42
|
gem.add_development_dependency('jeweler')
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.0
|
1
|
+
1.1.0
|
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{acts_as_revisionable}
|
8
|
-
s.version = "1.0
|
8
|
+
s.version = "1.1.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Brian Durand"]
|
12
|
-
s.date = %q{2011-
|
12
|
+
s.date = %q{2011-06-23}
|
13
13
|
s.description = %q{ActiveRecord extension that provides revision support so that history can be tracked and changes can be reverted. Emphasis for this plugin versus similar ones is including associations, saving on storage, and extensibility of the model.}
|
14
14
|
s.email = %q{brian@embellishedvisions.com}
|
15
15
|
s.extra_rdoc_files = [
|
@@ -18,44 +18,42 @@ Gem::Specification.new do |s|
|
|
18
18
|
s.files = [
|
19
19
|
"MIT-LICENSE",
|
20
20
|
"README.rdoc",
|
21
|
+
"RELEASES.txt",
|
21
22
|
"Rakefile",
|
22
23
|
"VERSION",
|
23
24
|
"acts_as_revisionable.gemspec",
|
24
25
|
"lib/acts_as_revisionable.rb",
|
25
26
|
"lib/acts_as_revisionable/revision_record.rb",
|
26
27
|
"spec/acts_as_revisionable_spec.rb",
|
27
|
-
"spec/full_spec.rb",
|
28
28
|
"spec/revision_record_spec.rb",
|
29
|
-
"spec/spec_helper.rb"
|
29
|
+
"spec/spec_helper.rb",
|
30
|
+
"spec/version_1_1_upgrade_spec.rb"
|
30
31
|
]
|
31
32
|
s.homepage = %q{http://github.com/bdurand/acts_as_revisionable}
|
32
|
-
s.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc"]
|
33
|
+
s.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc", "MIT-LICENSE"]
|
33
34
|
s.require_paths = ["lib"]
|
34
|
-
s.rubygems_version = %q{1.
|
35
|
+
s.rubygems_version = %q{1.5.2}
|
35
36
|
s.summary = %q{ActiveRecord extension that provides revision support so that history can be tracked and changes can be reverted.}
|
36
|
-
s.test_files = [
|
37
|
-
"spec/acts_as_revisionable_spec.rb",
|
38
|
-
"spec/full_spec.rb",
|
39
|
-
"spec/revision_record_spec.rb",
|
40
|
-
"spec/spec_helper.rb"
|
41
|
-
]
|
42
37
|
|
43
38
|
if s.respond_to? :specification_version then
|
44
39
|
s.specification_version = 3
|
45
40
|
|
46
41
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
47
|
-
s.add_runtime_dependency(%q<activerecord>, [">= 2.
|
42
|
+
s.add_runtime_dependency(%q<activerecord>, [">= 2.3.5"])
|
43
|
+
s.add_development_dependency(%q<composite_primary_keys>, [">= 0"])
|
48
44
|
s.add_development_dependency(%q<sqlite3>, [">= 0"])
|
49
45
|
s.add_development_dependency(%q<rspec>, [">= 2.0.0"])
|
50
46
|
s.add_development_dependency(%q<jeweler>, [">= 0"])
|
51
47
|
else
|
52
|
-
s.add_dependency(%q<activerecord>, [">= 2.
|
48
|
+
s.add_dependency(%q<activerecord>, [">= 2.3.5"])
|
49
|
+
s.add_dependency(%q<composite_primary_keys>, [">= 0"])
|
53
50
|
s.add_dependency(%q<sqlite3>, [">= 0"])
|
54
51
|
s.add_dependency(%q<rspec>, [">= 2.0.0"])
|
55
52
|
s.add_dependency(%q<jeweler>, [">= 0"])
|
56
53
|
end
|
57
54
|
else
|
58
|
-
s.add_dependency(%q<activerecord>, [">= 2.
|
55
|
+
s.add_dependency(%q<activerecord>, [">= 2.3.5"])
|
56
|
+
s.add_dependency(%q<composite_primary_keys>, [">= 0"])
|
59
57
|
s.add_dependency(%q<sqlite3>, [">= 0"])
|
60
58
|
s.add_dependency(%q<rspec>, [">= 2.0.0"])
|
61
59
|
s.add_dependency(%q<jeweler>, [">= 0"])
|
data/lib/acts_as_revisionable.rb
CHANGED
@@ -1,11 +1,11 @@
|
|
1
1
|
require 'active_record'
|
2
|
-
require 'active_support
|
2
|
+
require 'active_support'
|
3
3
|
|
4
4
|
module ActsAsRevisionable
|
5
5
|
|
6
6
|
autoload :RevisionRecord, File.expand_path('../acts_as_revisionable/revision_record', __FILE__)
|
7
7
|
|
8
|
-
def self.included
|
8
|
+
def self.included(base)
|
9
9
|
base.extend(ActsMethods)
|
10
10
|
end
|
11
11
|
|
@@ -15,40 +15,77 @@ module ActsAsRevisionable
|
|
15
15
|
# kept for at least a certain amount of time (i.e. 2.weeks). Associations to be revisioned can be specified with
|
16
16
|
# the :associations option as an array of association names. To specify associations of associations, use a hash
|
17
17
|
# for that association with the association name as the key and the value as an array of sub associations.
|
18
|
-
# For instance, this declaration will revision
|
18
|
+
# For instance, this declaration will revision <tt>:tags</tt>, <tt>:comments</tt>, as well as the
|
19
|
+
# <tt>:ratings</tt> association on <tt>:comments</tt>:
|
19
20
|
#
|
20
21
|
# :associations => [:tags, {:comments => [:ratings]}]
|
21
22
|
#
|
22
|
-
# You can also pass an options of
|
23
|
+
# You can also pass an options of <tt>:on_update => true</tt> to automatically enable revisioning on every update.
|
23
24
|
# Otherwise you will need to perform your updates in a store_revision block. The reason for this is so that
|
24
25
|
# revisions for complex models with associations can be better controlled.
|
25
26
|
#
|
27
|
+
# You can keep a revisions of deleted records by passing <tt>:dependent => :keep</tt>. When a record is destroyed,
|
28
|
+
# an additional revision will be created and marked as trash. Trash records can be deleted by calling the
|
29
|
+
# <tt>empty_trash</tt> method. You can set <tt>:on_destroy => true</tt> to automatically create the trash revision
|
30
|
+
# whenever a record is destroyed. It is recommended that you turn both of these features on.
|
31
|
+
#
|
26
32
|
# A has_many :revision_records will also be added to the model for accessing the revisions.
|
27
|
-
def acts_as_revisionable
|
33
|
+
def acts_as_revisionable(options = {})
|
28
34
|
write_inheritable_attribute(:acts_as_revisionable_options, options)
|
29
35
|
class_inheritable_reader(:acts_as_revisionable_options)
|
30
36
|
extend ClassMethods
|
31
37
|
include InstanceMethods
|
32
38
|
has_many_options = {:as => :revisionable, :order => 'revision DESC', :class_name => "ActsAsRevisionable::RevisionRecord"}
|
33
|
-
has_many_options[:dependent] = :destroy unless options[:dependent] == :keep
|
39
|
+
has_many_options[:dependent] = :destroy unless options[:dependent] == :keep
|
34
40
|
has_many :revision_records, has_many_options
|
35
41
|
alias_method_chain :update, :revision if options[:on_update]
|
42
|
+
alias_method_chain :destroy, :revision if options[:on_destroy]
|
36
43
|
end
|
37
44
|
end
|
38
45
|
|
39
46
|
module ClassMethods
|
40
|
-
#
|
41
|
-
|
47
|
+
# Get a revision for a specified id.
|
48
|
+
def revision(id, revision_number)
|
49
|
+
RevisionRecord.find_revision(self, id, revision_number)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the last revision for a specified id.
|
53
|
+
def last_revision(id)
|
54
|
+
RevisionRecord.last_revision(self, id)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Load a revision for a record with a particular id. Associations added since the revision
|
58
|
+
# was created will still be in the restored record.
|
42
59
|
# If you want to save a revision with associations properly, use restore_revision!
|
43
|
-
def restore_revision
|
44
|
-
|
45
|
-
return
|
60
|
+
def restore_revision(id, revision_number)
|
61
|
+
revision_record = revision(id, revision_number)
|
62
|
+
return revision_record.restore if revision_record
|
46
63
|
end
|
47
64
|
|
48
65
|
# Load a revision for a record with a particular id and save it to the database. You should
|
49
66
|
# always use this method to save a revision if it has associations.
|
50
|
-
def restore_revision!
|
51
|
-
record = restore_revision(id,
|
67
|
+
def restore_revision!(id, revision_number)
|
68
|
+
record = restore_revision(id, revision_number)
|
69
|
+
if record
|
70
|
+
record.store_revision do
|
71
|
+
save_restorable_associations(record, revisionable_associations)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
return record
|
75
|
+
end
|
76
|
+
|
77
|
+
# Load the last revision for a record with the specified id. Associations added since the revision
|
78
|
+
# was created will still be in the restored record.
|
79
|
+
# If you want to save a revision with associations properly, use restore_last_revision!
|
80
|
+
def restore_last_revision(id)
|
81
|
+
revision_record = last_revision(id)
|
82
|
+
return revision_record.restore if revision_record
|
83
|
+
end
|
84
|
+
|
85
|
+
# Load the last revision for a record with the specified id and save it to the database. You should
|
86
|
+
# always use this method to save a revision if it has associations.
|
87
|
+
def restore_last_revision!(id)
|
88
|
+
record = restore_last_revision(id)
|
52
89
|
if record
|
53
90
|
record.store_revision do
|
54
91
|
save_restorable_associations(record, revisionable_associations)
|
@@ -58,7 +95,7 @@ module ActsAsRevisionable
|
|
58
95
|
end
|
59
96
|
|
60
97
|
# Returns a hash structure used to identify the revisioned associations.
|
61
|
-
def revisionable_associations
|
98
|
+
def revisionable_associations(options = acts_as_revisionable_options[:associations])
|
62
99
|
return nil unless options
|
63
100
|
options = [options] unless options.kind_of?(Array)
|
64
101
|
associations = {}
|
@@ -74,9 +111,14 @@ module ActsAsRevisionable
|
|
74
111
|
return associations
|
75
112
|
end
|
76
113
|
|
114
|
+
# Delete all revision records for deleted items that are older than the specified maximum age in seconds.
|
115
|
+
def empty_trash(max_age)
|
116
|
+
RevisionRecord.empty_trash(self, max_age)
|
117
|
+
end
|
118
|
+
|
77
119
|
private
|
78
120
|
|
79
|
-
def save_restorable_associations
|
121
|
+
def save_restorable_associations(record, associations)
|
80
122
|
record.class.transaction do
|
81
123
|
if associations.kind_of?(Hash)
|
82
124
|
associations.each_pair do |association, sub_associations|
|
@@ -91,7 +133,7 @@ module ActsAsRevisionable
|
|
91
133
|
end
|
92
134
|
else
|
93
135
|
if reflection == :has_many
|
94
|
-
existing = associated_records.
|
136
|
+
existing = associated_records.all
|
95
137
|
existing.each do |existing_association|
|
96
138
|
associated_records.delete(existing_association) unless associated_records.include?(existing_association)
|
97
139
|
end
|
@@ -112,42 +154,64 @@ module ActsAsRevisionable
|
|
112
154
|
module InstanceMethods
|
113
155
|
# Restore a revision of the record and return it. The record is not saved to the database. If there
|
114
156
|
# is a problem restoring values, errors will be added to the record.
|
115
|
-
def restore_revision
|
116
|
-
self.class.restore_revision(self.id,
|
157
|
+
def restore_revision(revision_number)
|
158
|
+
self.class.restore_revision(self.id, revision_number)
|
117
159
|
end
|
118
160
|
|
119
161
|
# Restore a revision of the record and save it along with restored associations.
|
120
|
-
def restore_revision!
|
121
|
-
self.class.restore_revision!(self.id,
|
162
|
+
def restore_revision!(revision_number)
|
163
|
+
self.class.restore_revision!(self.id, revision_number)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Get a specified revision record
|
167
|
+
def revision(revision_number)
|
168
|
+
self.class.revision(id, revision_number)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Get the last revision record
|
172
|
+
def last_revision
|
173
|
+
self.class.last_revision(id)
|
122
174
|
end
|
123
175
|
|
124
176
|
# Call this method to implement revisioning. The object changes should happen inside the block.
|
125
177
|
def store_revision
|
126
|
-
if new_record?
|
178
|
+
if new_record? || @revisions_disabled
|
127
179
|
return yield
|
128
180
|
else
|
129
181
|
retval = nil
|
130
182
|
revision = nil
|
131
183
|
begin
|
132
184
|
RevisionRecord.transaction do
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
185
|
+
begin
|
186
|
+
read_only = self.class.first(:conditions => {self.class.primary_key => self.id}, :readonly => true)
|
187
|
+
if read_only
|
188
|
+
revision = read_only.create_revision!
|
189
|
+
truncate_revisions!
|
190
|
+
end
|
191
|
+
rescue => e
|
192
|
+
puts e
|
193
|
+
logger.warn(e) if logger
|
137
194
|
end
|
138
195
|
|
139
196
|
disable_revisioning do
|
140
197
|
retval = yield
|
141
198
|
end
|
142
199
|
|
143
|
-
raise
|
200
|
+
raise ActiveRecord::Rollback unless errors.empty?
|
201
|
+
|
202
|
+
revision.trash! if destroyed?
|
144
203
|
end
|
145
204
|
rescue => e
|
146
205
|
# In case the database doesn't support transactions
|
147
206
|
if revision
|
148
|
-
|
207
|
+
begin
|
208
|
+
revision.destroy
|
209
|
+
rescue => e
|
210
|
+
puts e
|
211
|
+
logger.warn(e) if logger
|
212
|
+
end
|
149
213
|
end
|
150
|
-
raise e
|
214
|
+
raise e
|
151
215
|
end
|
152
216
|
return retval
|
153
217
|
end
|
@@ -161,7 +225,7 @@ module ActsAsRevisionable
|
|
161
225
|
end
|
162
226
|
|
163
227
|
# Truncate the number of revisions kept for this record. Available options are :limit and :minimum_age.
|
164
|
-
def truncate_revisions!
|
228
|
+
def truncate_revisions!(options = nil)
|
165
229
|
options = {:limit => acts_as_revisionable_options[:limit], :minimum_age => acts_as_revisionable_options[:minimum_age]} unless options
|
166
230
|
RevisionRecord.truncate_revisions(self.class, self.id, options)
|
167
231
|
end
|
@@ -179,9 +243,16 @@ module ActsAsRevisionable
|
|
179
243
|
return retval
|
180
244
|
end
|
181
245
|
|
246
|
+
# Destroy the record while recording the revision.
|
247
|
+
def destroy_with_revision
|
248
|
+
store_revision do
|
249
|
+
destroy_without_revision
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
182
253
|
private
|
183
254
|
|
184
|
-
#
|
255
|
+
# Update the record while recording the revision.
|
185
256
|
def update_with_revision
|
186
257
|
store_revision do
|
187
258
|
update_without_revision
|
@@ -3,21 +3,26 @@ require 'yaml'
|
|
3
3
|
|
4
4
|
module ActsAsRevisionable
|
5
5
|
class RevisionRecord < ActiveRecord::Base
|
6
|
-
|
6
|
+
|
7
7
|
before_create :set_revision_number
|
8
8
|
attr_reader :data_encoding
|
9
|
-
|
9
|
+
|
10
10
|
set_table_name :revision_records
|
11
|
-
|
11
|
+
|
12
12
|
class << self
|
13
13
|
# Find a specific revision record.
|
14
|
-
def find_revision
|
14
|
+
def find_revision(klass, id, revision)
|
15
15
|
find(:first, :conditions => {:revisionable_type => klass.base_class.to_s, :revisionable_id => id, :revision => revision})
|
16
16
|
end
|
17
|
+
|
18
|
+
# Find the last revision record for a class.
|
19
|
+
def last_revision(klass, id, revision = nil)
|
20
|
+
find(:first, :conditions => {:revisionable_type => klass.base_class.to_s, :revisionable_id => id}, :order => "revision DESC")
|
21
|
+
end
|
17
22
|
|
18
23
|
# Truncate the revisions for a record. Available options are :limit and :max_age.
|
19
|
-
def truncate_revisions
|
20
|
-
return unless options[:limit]
|
24
|
+
def truncate_revisions(revisionable_type, revisionable_id, options)
|
25
|
+
return unless options[:limit] || options[:minimum_age]
|
21
26
|
|
22
27
|
conditions = ['revisionable_type = ? AND revisionable_id = ?', revisionable_type.base_class.to_s, revisionable_id]
|
23
28
|
if options[:minimum_age]
|
@@ -30,23 +35,48 @@ module ActsAsRevisionable
|
|
30
35
|
delete_all(['revisionable_type = ? AND revisionable_id = ? AND revision <= ?', revisionable_type.base_class.to_s, revisionable_id, start_deleting_revision.revision])
|
31
36
|
end
|
32
37
|
end
|
33
|
-
|
38
|
+
|
39
|
+
# Empty the trash by deleting records older than the specified maximum age in seconds.
|
40
|
+
# The +revisionable_type+ argument specifies the class to delete revision records for.
|
41
|
+
def empty_trash(revisionable_type, max_age)
|
42
|
+
sql = "revisionable_id IN (SELECT revisionable_id from #{table_name} WHERE created_at <= ? AND revisionable_type = ? AND trash = ?) AND revisionable_type = ?"
|
43
|
+
args = [max_age.ago, revisionable_type.name, true, revisionable_type.name]
|
44
|
+
delete_all([sql] + args)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Create the table to store revision records.
|
34
48
|
def create_table
|
35
49
|
connection.create_table :revision_records do |t|
|
36
50
|
t.string :revisionable_type, :null => false, :limit => 100
|
37
51
|
t.integer :revisionable_id, :null => false
|
38
52
|
t.integer :revision, :null => false
|
39
|
-
t.binary :data, :limit => (connection.adapter_name
|
53
|
+
t.binary :data, :limit => (connection.adapter_name.match(/mysql/i) ? 5.megabytes : nil)
|
40
54
|
t.timestamp :created_at, :null => false
|
55
|
+
t.boolean :trash, :default => false
|
56
|
+
t.string :label, :limit => 255, :null => true
|
41
57
|
end
|
58
|
+
|
59
|
+
connection.add_index :revision_records, :revisionable_id, :name => "revision_record_id"
|
60
|
+
connection.add_index :revision_records, [:revisionable_type, :created_at, :trash], :name => "revisionable_type_and_created_at"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Update a version 1.0.x table to the latest version. This method only needs to be called
|
64
|
+
# from a migration if you originally created the table with a version 1.0.x version of the gem.
|
65
|
+
def update_version_1_table
|
66
|
+
# Added in version 1.1.0
|
67
|
+
connection.add_column(:revision_records, :trash, :boolean, :default => false)
|
68
|
+
connection.add_column(:revision_records, :label, :string, :limit => 255, :null => true)
|
69
|
+
connection.add_index :revision_records, :revisionable_id, :name => "revision_record_id"
|
70
|
+
connection.add_index :revision_records, [:revisionable_type, :created_at, :trash], :name => "revisionable_type_and_created_at"
|
42
71
|
|
43
|
-
|
72
|
+
# Removed in 1.1.0
|
73
|
+
connection.remove_index(:revision_records, :name => "revisionable")
|
44
74
|
end
|
45
75
|
end
|
46
|
-
|
76
|
+
|
47
77
|
# Create a revision record based on a record passed in. The attributes of the original record will
|
48
78
|
# be serialized. If it uses the acts_as_revisionable behavior, associations will be revisioned as well.
|
49
|
-
def initialize
|
79
|
+
def initialize(record, encoding = :ruby)
|
50
80
|
super({})
|
51
81
|
@data_encoding = encoding
|
52
82
|
self.revisionable_type = record.class.base_class.name
|
@@ -54,19 +84,19 @@ module ActsAsRevisionable
|
|
54
84
|
associations = record.class.revisionable_associations if record.class.respond_to?(:revisionable_associations)
|
55
85
|
self.data = Zlib::Deflate.deflate(serialize_hash(serialize_attributes(record, associations)))
|
56
86
|
end
|
57
|
-
|
87
|
+
|
58
88
|
# Returns the attributes that are saved in the revision.
|
59
89
|
def revision_attributes
|
60
90
|
return nil unless self.data
|
61
91
|
uncompressed = Zlib::Inflate.inflate(self.data)
|
62
92
|
deserialize_hash(uncompressed)
|
63
93
|
end
|
64
|
-
|
94
|
+
|
65
95
|
# Restore the revision to the original record. If any errors are encountered restoring attributes, they
|
66
96
|
# will be added to the errors object of the restored record.
|
67
97
|
def restore
|
68
98
|
restore_class = self.revisionable_type.constantize
|
69
|
-
|
99
|
+
|
70
100
|
# Check if we have a type field, if yes, assume single table inheritance and restore the actual class instead of the stored base class
|
71
101
|
sti_type = self.revision_attributes[restore_class.inheritance_column]
|
72
102
|
if sti_type
|
@@ -80,32 +110,21 @@ module ActsAsRevisionable
|
|
80
110
|
# Seems our assumption was wrong and we have no STI
|
81
111
|
end
|
82
112
|
end
|
83
|
-
|
84
|
-
attrs, association_attrs = attributes_and_associations(restore_class, self.revision_attributes)
|
85
|
-
|
113
|
+
|
86
114
|
record = restore_class.new
|
87
|
-
|
88
|
-
begin
|
89
|
-
record.send("#{key}=", value)
|
90
|
-
rescue
|
91
|
-
record.errors.add(key.to_sym, "could not be restored to #{value.inspect}")
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
association_attrs.each_pair do |association, attribute_values|
|
96
|
-
restore_association(record, association, attribute_values)
|
97
|
-
end
|
98
|
-
|
99
|
-
record.instance_variable_set(:@new_record, nil) if record.instance_variable_defined?(:@new_record)
|
100
|
-
# ActiveRecord 3.0.2 and 3.0.3 used @persisted instead of @new_record
|
101
|
-
record.instance_variable_set(:@persisted, true) if record.instance_variable_defined?(:@persisted)
|
102
|
-
|
115
|
+
restore_record(record, revision_attributes)
|
103
116
|
return record
|
104
117
|
end
|
105
118
|
|
119
|
+
# Mark this revision as being trash. When trash records are restored, all
|
120
|
+
# their revision history is restored as well.
|
121
|
+
def trash!
|
122
|
+
update_attribute(:trash, true)
|
123
|
+
end
|
124
|
+
|
106
125
|
private
|
107
|
-
|
108
|
-
def serialize_hash
|
126
|
+
|
127
|
+
def serialize_hash(hash)
|
109
128
|
encoding = data_encoding.blank? ? :ruby : data_encoding
|
110
129
|
case encoding.to_sym
|
111
130
|
when :yaml
|
@@ -116,8 +135,8 @@ module ActsAsRevisionable
|
|
116
135
|
return Marshal.dump(hash)
|
117
136
|
end
|
118
137
|
end
|
119
|
-
|
120
|
-
def deserialize_hash
|
138
|
+
|
139
|
+
def deserialize_hash(data)
|
121
140
|
if data.starts_with?('---')
|
122
141
|
return YAML.load(data)
|
123
142
|
elsif data.starts_with?('<?xml')
|
@@ -126,17 +145,17 @@ module ActsAsRevisionable
|
|
126
145
|
return Marshal.load(data)
|
127
146
|
end
|
128
147
|
end
|
129
|
-
|
148
|
+
|
130
149
|
def set_revision_number
|
131
150
|
last_revision = self.class.maximum(:revision, :conditions => {:revisionable_type => self.revisionable_type, :revisionable_id => self.revisionable_id}) || 0
|
132
151
|
self.revision = last_revision + 1
|
133
152
|
end
|
134
153
|
|
135
|
-
def serialize_attributes
|
154
|
+
def serialize_attributes(record, revisionable_associations, already_serialized = {})
|
136
155
|
return if already_serialized["#{record.class}.#{record.id}"]
|
137
156
|
attrs = record.attributes.dup
|
138
157
|
already_serialized["#{record.class}.#{record.id}"] = true
|
139
|
-
|
158
|
+
|
140
159
|
if revisionable_associations.kind_of?(Hash)
|
141
160
|
record.class.reflections.values.each do |association|
|
142
161
|
if revisionable_associations[association.name]
|
@@ -151,19 +170,19 @@ module ActsAsRevisionable
|
|
151
170
|
attrs[assoc_name] = nil
|
152
171
|
end
|
153
172
|
elsif association.macro == :has_and_belongs_to_many
|
154
|
-
attrs[assoc_name] = record.send("#{association.name.to_s.singularize}_ids"
|
173
|
+
attrs[assoc_name] = record.send("#{association.name.to_s.singularize}_ids")
|
155
174
|
end
|
156
175
|
end
|
157
176
|
end
|
158
177
|
end
|
159
|
-
|
178
|
+
|
160
179
|
return attrs
|
161
180
|
end
|
162
|
-
|
163
|
-
def attributes_and_associations
|
181
|
+
|
182
|
+
def attributes_and_associations(klass, hash)
|
164
183
|
attrs = {}
|
165
184
|
association_attrs = {}
|
166
|
-
|
185
|
+
|
167
186
|
if hash
|
168
187
|
hash.each_pair do |key, value|
|
169
188
|
if klass.reflections.include?(key.to_sym)
|
@@ -173,32 +192,29 @@ module ActsAsRevisionable
|
|
173
192
|
end
|
174
193
|
end
|
175
194
|
end
|
176
|
-
|
195
|
+
|
177
196
|
return [attrs, association_attrs]
|
178
197
|
end
|
179
|
-
|
180
|
-
def restore_association
|
198
|
+
|
199
|
+
def restore_association(record, association, association_attributes)
|
181
200
|
association = association.to_sym
|
182
201
|
reflection = record.class.reflections[association]
|
183
202
|
associated_record = nil
|
184
|
-
|
185
|
-
|
203
|
+
|
186
204
|
begin
|
187
205
|
if reflection.macro == :has_many
|
188
206
|
if association_attributes.kind_of?(Array)
|
189
|
-
record.send("#{association}="
|
207
|
+
record.send("#{association}=", [])
|
190
208
|
association_attributes.each do |attrs|
|
191
209
|
restore_association(record, association, attrs)
|
192
210
|
end
|
193
211
|
else
|
194
212
|
associated_record = record.send(association).build
|
195
|
-
associated_record
|
196
|
-
exists = associated_record.class.find(associated_record.id) rescue nil
|
213
|
+
restore_record(associated_record, association_attributes)
|
197
214
|
end
|
198
215
|
elsif reflection.macro == :has_one
|
199
216
|
associated_record = reflection.klass.new
|
200
|
-
associated_record
|
201
|
-
exists = associated_record.class.find(associated_record.id) rescue nil
|
217
|
+
restore_record(associated_record, association_attributes)
|
202
218
|
record.send("#{association}=", associated_record)
|
203
219
|
elsif reflection.macro == :has_and_belongs_to_many
|
204
220
|
record.send("#{association.to_s.singularize}_ids=", association_attributes)
|
@@ -206,27 +222,41 @@ module ActsAsRevisionable
|
|
206
222
|
rescue => e
|
207
223
|
record.errors.add(association, "could not be restored from the revision: #{e.message}")
|
208
224
|
end
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
225
|
+
|
226
|
+
if associated_record && !associated_record.errors.empty?
|
227
|
+
record.errors.add(association, 'could not be restored from the revision')
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Restore a record and all its associations.
|
232
|
+
def restore_record(record, attributes)
|
233
|
+
primary_key = record.class.primary_key
|
234
|
+
primary_key = [primary_key] unless primary_key.is_a?(Array)
|
235
|
+
primary_key.each do |key|
|
236
|
+
record.send("#{key.to_s}=", attributes[key.to_s])
|
237
|
+
end
|
238
|
+
|
239
|
+
attrs, association_attrs = attributes_and_associations(record.class, attributes)
|
213
240
|
attrs.each_pair do |key, value|
|
214
241
|
begin
|
215
|
-
|
242
|
+
record.send("#{key}=", value)
|
216
243
|
rescue
|
217
|
-
|
218
|
-
record.errors.add(association, "could not be restored from the revision") unless record.errors[association]
|
244
|
+
record.errors.add(key.to_sym, "could not be restored to #{value.inspect}")
|
219
245
|
end
|
220
246
|
end
|
221
|
-
|
247
|
+
|
222
248
|
association_attrs.each_pair do |key, values|
|
223
|
-
restore_association(
|
249
|
+
restore_association(record, key, values) if values
|
224
250
|
end
|
225
251
|
|
252
|
+
# Check if the record already exists in the database and restore its state.
|
253
|
+
# This must be done last because otherwise associations on an existing record
|
254
|
+
# can be deleted when a revision is restored to memory.
|
255
|
+
exists = record.class.find(record.send(record.class.primary_key)) rescue nil
|
226
256
|
if exists
|
227
|
-
|
257
|
+
record.instance_variable_set(:@new_record, nil) if record.instance_variable_defined?(:@new_record)
|
228
258
|
# ActiveRecord 3.0.2 and 3.0.3 used @persisted instead of @new_record
|
229
|
-
|
259
|
+
record.instance_variable_set(:@persisted, true) if record.instance_variable_defined?(:@persisted)
|
230
260
|
end
|
231
261
|
end
|
232
262
|
end
|