/
acts_as_revisionable.rb
305 lines (272 loc) · 12 KB
/
acts_as_revisionable.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
require 'active_record'
require 'active_support'
module ActsAsRevisionable
autoload :RevisionRecord, File.expand_path('../acts_as_revisionable/revision_record', __FILE__)
def self.included(base)
base.extend(ActsMethods)
end
module ActsMethods
# Calling acts_as_revisionable will inject the revisionable behavior into the class. Specifying a :limit option
# will limit the number of revisions that are kept per record. Specifying :minimum_age will ensure that revisions are
# kept for at least a certain amount of time (i.e. 2.weeks). Associations to be revisioned can be specified with
# the :associations option as an array of association names. To specify associations of associations, use a hash
# for that association with the association name as the key and the value as an array of sub associations.
# For instance, this declaration will revision <tt>:tags</tt>, <tt>:comments</tt>, as well as the
# <tt>:ratings</tt> association on <tt>:comments</tt>:
#
# :associations => [:tags, {:comments => [:ratings]}]
#
# You can also pass an options of <tt>:on_update => true</tt> to automatically enable revisioning on every update.
# Otherwise you will need to perform your updates in a store_revision block. The reason for this is so that
# revisions for complex models with associations can be better controlled.
#
# You can keep a revisions of deleted records by passing <tt>:dependent => :keep</tt>. When a record is destroyed,
# an additional revision will be created and marked as trash. Trash records can be deleted by calling the
# <tt>empty_trash</tt> method. You can set <tt>:on_destroy => true</tt> to automatically create the trash revision
# whenever a record is destroyed. It is recommended that you turn both of these features on.
#
# Revision records can be extended to include other fields as needed and set with the <tt>:meta</tt> option.
# In order to extend a revision record, you must add columns to the database table. The values of the <tt>:meta</tt>
# option hash will be provided to the newly created revision record.
#
# acts_as_revisionable :meta => {
# :updated_by => :last_updated_by,
# :label => lambda{|record| "Updated by #{record.updated_by} at #{record.updated_at}"},
# :version => 1
# }
#
# The values to the <tt>:meta</tt> hash can be either symbols or Procs. If it is a symbol, the method
# so named will be called on the record being revisioned. If it is a Proc, it will be called with the
# record as the argument. Any other class will be sent directly to the revision record.
#
# You can also use a subclass of RevisionRecord if desired so that you can add your own model logic as
# necessary. To specify a different class to use for revision records, simply subclass RevisionRecord and
# provide the class name to the <tt>:class_name</tt> option.
#
# acts_as_revisionable :class_name => "MyRevisionRecord"
#
# A has_many :revision_records will also be added to the model for accessing the revisions.
def acts_as_revisionable(options = {})
class_attribute :acts_as_revisionable_options, :instance_writer => false, :instance_reader => false
defaults = {:class_name => "ActsAsRevisionable::RevisionRecord"}
self.acts_as_revisionable_options = defaults.merge(options)
acts_as_revisionable_options[:class_name] = acts_as_revisionable_options[:class_name].name if acts_as_revisionable_options[:class_name].is_a?(Class)
extend ClassMethods
include InstanceMethods
class_name = acts_as_revisionable_options[:class_name].to_s if acts_as_revisionable_options[:class_name]
has_many_options = {:as => :revisionable, :order => 'revision DESC', :class_name => class_name}
has_many_options[:dependent] = :destroy unless options[:dependent] == :keep
has_many :revision_records, has_many_options
alias_method_chain :update, :revision if options[:on_update]
alias_method_chain :destroy, :revision if options[:on_destroy]
end
end
module ClassMethods
# Get a revision for a specified id.
def revision(id, revision_number)
revision_record_class.find_revision(self, id, revision_number)
end
# Get the last revision for a specified id.
def last_revision(id)
revision_record_class.last_revision(self, id)
end
# Load a revision for a record with a particular id. Associations added since the revision
# was created will still be in the restored record.
# If you want to save a revision with associations properly, use restore_revision!
def restore_revision(id, revision_number)
revision_record = revision(id, revision_number)
return revision_record.restore if revision_record
end
# Load a revision for a record with a particular id and save it to the database. You should
# always use this method to save a revision if it has associations.
def restore_revision!(id, revision_number)
record = restore_revision(id, revision_number)
if record
record.store_revision do
save_restorable_associations(record, revisionable_associations)
end
end
return record
end
# Load the last revision for a record with the specified id. Associations added since the revision
# was created will still be in the restored record.
# If you want to save a revision with associations properly, use restore_last_revision!
def restore_last_revision(id)
revision_record = last_revision(id)
return revision_record.restore if revision_record
end
# Load the last revision for a record with the specified id and save it to the database. You should
# always use this method to save a revision if it has associations.
def restore_last_revision!(id)
record = restore_last_revision(id)
if record
record.store_revision do
save_restorable_associations(record, revisionable_associations)
end
end
return record
end
# Returns a hash structure used to identify the revisioned associations.
def revisionable_associations(options = acts_as_revisionable_options[:associations])
return nil unless options
options = [options] unless options.kind_of?(Array)
associations = {}
options.each do |association|
if association.kind_of?(Symbol)
associations[association] = true
elsif association.kind_of?(Hash)
association.each_pair do |key, value|
associations[key] = revisionable_associations(value)
end
end
end
return associations
end
# Delete all revision records for deleted items that are older than the specified maximum age in seconds.
def empty_trash(max_age)
revision_record_class.empty_trash(self, max_age)
end
def revision_record_class
acts_as_revisionable_options[:class_name].constantize
end
private
def save_restorable_associations(record, associations)
record.class.transaction do
if associations.kind_of?(Hash)
associations.each_pair do |association, sub_associations|
associated_records = record.send(association)
reflection = record.class.reflections[association].macro
if reflection == :has_and_belongs_to_many
associated_records = associated_records.collect{|r| r}
record.send(association, true).clear
associated_records.each do |assoc_record|
record.send(association) << assoc_record
end
else
if reflection == :has_many
existing = associated_records.all
existing.each do |existing_association|
associated_records.delete(existing_association) unless associated_records.include?(existing_association)
end
end
associated_records = [associated_records] unless associated_records.kind_of?(Array)
associated_records.each do |associated_record|
save_restorable_associations(associated_record, sub_associations) if associated_record
end
end
end
end
record.save! unless record.new_record?
end
end
end
module InstanceMethods
# Restore a revision of the record and return it. The record is not saved to the database. If there
# is a problem restoring values, errors will be added to the record.
def restore_revision(revision_number)
self.class.restore_revision(self.id, revision_number)
end
# Restore a revision of the record and save it along with restored associations.
def restore_revision!(revision_number)
self.class.restore_revision!(self.id, revision_number)
end
# Get a specified revision record
def revision(revision_number)
self.class.revision(id, revision_number)
end
# Get the last revision record
def last_revision
self.class.last_revision(id)
end
# Call this method to implement revisioning. The object changes should happen inside the block.
def store_revision
if new_record? || @revisions_disabled
return yield
else
retval = nil
revision = nil
begin
revision_record_class.transaction do
begin
read_only = self.class.first(:conditions => {self.class.primary_key => self.id}, :readonly => true)
if read_only
revision = read_only.create_revision!
truncate_revisions!
end
rescue => e
logger.warn(e) if logger
end
disable_revisioning do
retval = yield
end
raise ActiveRecord::Rollback unless errors.empty?
revision.trash! if destroyed?
end
rescue => e
# In case the database doesn't support transactions
if revision
begin
revision.destroy
rescue => e
logger.warn(e) if logger
end
end
raise e
end
return retval
end
end
# Create a revision record based on this record and save it to the database.
def create_revision!
revision_options = self.class.acts_as_revisionable_options
revision = revision_record_class.new(self, revision_options[:encoding])
if revision_options[:meta].is_a?(Hash)
revision_options[:meta].each do |attribute, value|
case value
when Symbol
value = self.send(value)
when Proc
value = value.call(self)
end
revision.send("#{attribute}=", value)
end
end
revision.save!
return revision
end
# Truncate the number of revisions kept for this record. Available options are :limit and :minimum_age.
def truncate_revisions!(options = nil)
options = {:limit => self.class.acts_as_revisionable_options[:limit], :minimum_age => self.class.acts_as_revisionable_options[:minimum_age]} unless options
revision_record_class.truncate_revisions(self.class, self.id, options)
end
# Disable the revisioning behavior inside of a block passed to the method.
def disable_revisioning
save_val = @revisions_disabled
retval = nil
begin
@revisions_disabled = true
retval = yield if block_given?
ensure
@revisions_disabled = save_val
end
return retval
end
# Destroy the record while recording the revision.
def destroy_with_revision
store_revision do
destroy_without_revision
end
end
def revision_record_class
self.class.revision_record_class
end
private
# Update the record while recording the revision.
def update_with_revision
store_revision do
update_without_revision
end
end
end
end
ActiveRecord::Base.send(:include, ActsAsRevisionable)