acts_as_revisionable 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ pkg
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Brian Durand
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,56 @@
1
+ = Acts As Revisionable
2
+
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
+
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 :limit option to the acts_as_revisionable statement:
6
+
7
+ acts_as_revisionable :limit => 25
8
+
9
+ You can insure that revisions are kept for a minimum length of time by specifying :minimum_age:
10
+
11
+ acts_as_revisionable :limit => 15, :minimum_age => 2.weeks
12
+
13
+ Revisions are accessible on a record via a has_many :revision_records association. The revision records are sorted in reverse order. The revisions will be destroyed along with the parent record.
14
+
15
+ == Associations
16
+
17
+ You can specify associations that you'd like to include in each revision by providing a list of them with the :associations 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
+
19
+ :associations => :comments # include has_many :comments in the revision
20
+ :associations => [:comments, :tags] # include both :comments and :tags
21
+ :associations => [{:comments => :ratings}, :tags] # include both :comments and :tags as well as has_many :ratings on the comments
22
+
23
+ You can only revision has_many, has_one, and has_and_belongs_to_many associations. You cannot revision belongs_to.
24
+
25
+ == Storing Revisions
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 :on_update => true 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
+
29
+ store_revision do
30
+ model.update_has_many_records(params[:has_many])
31
+ model.save!
32
+ end
33
+
34
+ The revision will only be saved if the block successfully updates the record to the database.
35
+
36
+ == Restoring Revisions
37
+
38
+ You can restore revisions into an object in memory by calling restore_revision with the revision number. This will return a new object with all the attributes and associations restored from the revision record. The object will not have been saved yet to the database. If any errors were encountered restoring an attribute or association, an error will be added to the record errors. This should make it easy to reuse the model's edit interface for restoring the revision. You can also call restore_revision! to restore the record and save it and all it's associations.
39
+
40
+ If you are revisioning associations, you should always call restore_revision! instead of simply restoring the revision and calling save. Otherwise associations added since the revision will not be removed. This is a limitation on how active record handles removing revisions.
41
+
42
+ == Serialization
43
+
44
+ By default revisions are serialized using Ruby's Marshal class. This is the most reliable mechanism, but the least portable. As an alternative, you can specify <tt>:encoding => :yaml</tt> in the acts_as_revisionable options. This will store the data as YAML. There are some issues with the Ruby 1.8 YAML parser where some values are not deserialized properly, so only use this option if you really need the portability. You can also specify <tt>:encoding => :xml</tt> to store the revisions as XML. This should work fine unless the record contains binary data.
45
+
46
+ == Setup
47
+
48
+ To create the table structure for ActsAsRevisionable::RevisionRecord, simply add a migration to your project that calls
49
+
50
+ ActsAsRevisionable::RevisionRecord.create_table
51
+
52
+ == Destroying
53
+
54
+ By default, the revision history of a record is destroyed along with the record, you could be at risk of losing your revision history. However, this gem was developed as a companion to the acts_as_trashable[http://github.com/bdurand/acts_as_trashable] gem. With this gem, you can store destroyed records for a set period along with all dependent associations. Both gems use similar code and interfaces. It is recommended that you always use acts_as_trashable along side of acts_as_revisionable.
55
+
56
+ Alternatively, you can specify the :dependent => :keep in options of the acts_as_revisionable call to keep all the revisions after a record is destroyed.
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/rdoctask'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ begin
9
+ require 'spec/rake/spectask'
10
+ desc 'Test the gem.'
11
+ Spec::Rake::SpecTask.new(:test) do |t|
12
+ t.spec_files = FileList.new('spec/**/*_spec.rb')
13
+ end
14
+ rescue LoadError
15
+ tast :test do
16
+ STDERR.puts "You must have rspec >= 1.3.0 to run the tests"
17
+ end
18
+ end
19
+
20
+ desc 'Generate documentation for the gem.'
21
+ Rake::RDocTask.new(:rdoc) do |rdoc|
22
+ rdoc.rdoc_dir = 'rdoc'
23
+ rdoc.options << '--title' << 'Acts As Revisionable' << '--line-numbers' << '--inline-source' << '--main' << 'README.rdoc'
24
+ rdoc.rdoc_files.include('README.rdoc')
25
+ rdoc.rdoc_files.include('lib/**/*.rb')
26
+ end
27
+
28
+ begin
29
+ require 'jeweler'
30
+ Jeweler::Tasks.new do |gem|
31
+ gem.name = "acts_as_revisionable"
32
+ gem.summary = %Q{ActiveRecord extension that provides revision support so that history can be tracked and changes can be reverted.}
33
+ gem.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.)
34
+ gem.email = "brian@embellishedvisions.com"
35
+ gem.homepage = "http://github.com/bdurand/acts_as_revisionable"
36
+ gem.authors = ["Brian Durand"]
37
+ gem.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc"]
38
+
39
+ gem.add_dependency('activerecord', '>= 2.2')
40
+ gem.add_development_dependency('sqlite3')
41
+ gem.add_development_dependency('rspec', '>= 1.3.0')
42
+ gem.add_development_dependency('jeweler')
43
+ end
44
+
45
+ Jeweler::GemcutterTasks.new
46
+ rescue LoadError
47
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.3
@@ -0,0 +1,66 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{acts_as_revisionable}
8
+ s.version = "1.0.3"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Brian Durand"]
12
+ s.date = %q{2010-06-22}
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
+ s.email = %q{brian@embellishedvisions.com}
15
+ s.extra_rdoc_files = [
16
+ "README.rdoc"
17
+ ]
18
+ s.files = [
19
+ ".gitignore",
20
+ "MIT-LICENSE",
21
+ "README.rdoc",
22
+ "Rakefile",
23
+ "VERSION",
24
+ "acts_as_revisionable.gemspec",
25
+ "lib/acts_as_revisionable.rb",
26
+ "lib/acts_as_revisionable/revision_record.rb",
27
+ "spec/acts_as_revisionable_spec.rb",
28
+ "spec/full_spec.rb",
29
+ "spec/revision_record_spec.rb",
30
+ "spec/spec_helper.rb"
31
+ ]
32
+ s.homepage = %q{http://github.com/bdurand/acts_as_revisionable}
33
+ s.rdoc_options = ["--charset=UTF-8", "--main", "README.rdoc"]
34
+ s.require_paths = ["lib"]
35
+ s.rubygems_version = %q{1.3.7}
36
+ s.summary = %q{ActiveRecord extension that provides revision support so that history can be tracked and changes can be reverted.}
37
+ s.test_files = [
38
+ "spec/acts_as_revisionable_spec.rb",
39
+ "spec/full_spec.rb",
40
+ "spec/revision_record_spec.rb",
41
+ "spec/spec_helper.rb"
42
+ ]
43
+
44
+ if s.respond_to? :specification_version then
45
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
46
+ s.specification_version = 3
47
+
48
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
49
+ s.add_runtime_dependency(%q<activerecord>, [">= 2.2"])
50
+ s.add_development_dependency(%q<sqlite3>, [">= 0"])
51
+ s.add_development_dependency(%q<rspec>, [">= 1.3.0"])
52
+ s.add_development_dependency(%q<jeweler>, [">= 0"])
53
+ else
54
+ s.add_dependency(%q<activerecord>, [">= 2.2"])
55
+ s.add_dependency(%q<sqlite3>, [">= 0"])
56
+ s.add_dependency(%q<rspec>, [">= 1.3.0"])
57
+ s.add_dependency(%q<jeweler>, [">= 0"])
58
+ end
59
+ else
60
+ s.add_dependency(%q<activerecord>, [">= 2.2"])
61
+ s.add_dependency(%q<sqlite3>, [">= 0"])
62
+ s.add_dependency(%q<rspec>, [">= 1.3.0"])
63
+ s.add_dependency(%q<jeweler>, [">= 0"])
64
+ end
65
+ end
66
+
@@ -0,0 +1,227 @@
1
+ require 'zlib'
2
+ require 'yaml'
3
+
4
+ module ActsAsRevisionable
5
+ class RevisionRecord < ActiveRecord::Base
6
+
7
+ before_create :set_revision_number
8
+ attr_reader :data_encoding
9
+
10
+ set_table_name :revision_records
11
+
12
+ class << self
13
+ # Find a specific revision record.
14
+ def find_revision (klass, id, revision)
15
+ find(:first, :conditions => {:revisionable_type => klass.base_class.to_s, :revisionable_id => id, :revision => revision})
16
+ end
17
+
18
+ # Truncate the revisions for a record. Available options are :limit and :max_age.
19
+ def truncate_revisions (revisionable_type, revisionable_id, options)
20
+ return unless options[:limit] or options[:minimum_age]
21
+
22
+ conditions = ['revisionable_type = ? AND revisionable_id = ?', revisionable_type.base_class.to_s, revisionable_id]
23
+ if options[:minimum_age]
24
+ conditions.first << ' AND created_at <= ?'
25
+ conditions << options[:minimum_age].ago
26
+ end
27
+
28
+ start_deleting_revision = find(:first, :conditions => conditions, :order => 'revision DESC', :offset => options[:limit])
29
+ if start_deleting_revision
30
+ delete_all(['revisionable_type = ? AND revisionable_id = ? AND revision <= ?', revisionable_type.base_class.to_s, revisionable_id, start_deleting_revision.revision])
31
+ end
32
+ end
33
+
34
+ def create_table
35
+ connection.create_table :revision_records do |t|
36
+ t.string :revisionable_type, :null => false, :limit => 100
37
+ t.integer :revisionable_id, :null => false
38
+ t.integer :revision, :null => false
39
+ t.binary :data, :limit => 5.megabytes
40
+ t.timestamp :created_at, :null => false
41
+ end
42
+
43
+ connection.add_index :revision_records, [:revisionable_type, :revisionable_id, :revision], :name => "revisionable", :unique => true
44
+ end
45
+ end
46
+
47
+ # Create a revision record based on a record passed in. The attributes of the original record will
48
+ # be serialized. If it uses the acts_as_revisionable behavior, associations will be revisioned as well.
49
+ def initialize (record, encoding = :ruby)
50
+ super({})
51
+ @data_encoding = encoding
52
+ self.revisionable_type = record.class.base_class.name
53
+ self.revisionable_id = record.id
54
+ associations = record.class.revisionable_associations if record.class.respond_to?(:revisionable_associations)
55
+ self.data = Zlib::Deflate.deflate(serialize_hash(serialize_attributes(record, associations)))
56
+ end
57
+
58
+ # Returns the attributes that are saved in the revision.
59
+ def revision_attributes
60
+ return nil unless self.data
61
+ uncompressed = Zlib::Inflate.inflate(self.data)
62
+ deserialize_hash(uncompressed)
63
+ end
64
+
65
+ # Restore the revision to the original record. If any errors are encountered restoring attributes, they
66
+ # will be added to the errors object of the restored record.
67
+ def restore
68
+ restore_class = self.revisionable_type.constantize
69
+
70
+ # 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
+ sti_type = self.revision_attributes[restore_class.inheritance_column]
72
+ if sti_type
73
+ begin
74
+ unless restore_class.store_full_sti_class
75
+ sti_type = (/^::/ =~ type_name) ? type_name : "#{restore_class.parent.name}::#{type_name}"
76
+ end
77
+ restore_class = sti_type.constantize
78
+ rescue NameError
79
+ raise
80
+ # Seems our assumption was wrong and we have no STI
81
+ end
82
+ end
83
+
84
+ attrs, association_attrs = attributes_and_associations(restore_class, self.revision_attributes)
85
+
86
+ record = restore_class.new
87
+ attrs.each_pair do |key, value|
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)
100
+
101
+ return record
102
+ end
103
+
104
+ private
105
+
106
+ def serialize_hash (hash)
107
+ encoding = data_encoding.blank? ? :ruby : data_encoding
108
+ case encoding.to_sym
109
+ when :yaml
110
+ return YAML.dump(hash)
111
+ when :xml
112
+ return hash.to_xml(:root => 'revision')
113
+ else
114
+ return Marshal.dump(hash)
115
+ end
116
+ end
117
+
118
+ def deserialize_hash (data)
119
+ if data.starts_with?('---')
120
+ return YAML.load(data)
121
+ elsif data.starts_with?('<?xml')
122
+ return Hash.from_xml(data)['revision']
123
+ else
124
+ return Marshal.load(data)
125
+ end
126
+ end
127
+
128
+ def set_revision_number
129
+ last_revision = self.class.maximum(:revision, :conditions => {:revisionable_type => self.revisionable_type, :revisionable_id => self.revisionable_id}) || 0
130
+ self.revision = last_revision + 1
131
+ end
132
+
133
+ def serialize_attributes (record, revisionable_associations, already_serialized = {})
134
+ return if already_serialized["#{record.class}.#{record.id}"]
135
+ attrs = record.attributes.dup
136
+ already_serialized["#{record.class}.#{record.id}"] = true
137
+
138
+ if revisionable_associations.kind_of?(Hash)
139
+ record.class.reflections.values.each do |association|
140
+ if revisionable_associations[association.name]
141
+ assoc_name = association.name.to_s
142
+ if association.macro == :has_many
143
+ attrs[assoc_name] = record.send(association.name).collect{|r| serialize_attributes(r, revisionable_associations[association.name], already_serialized)}
144
+ elsif association.macro == :has_one
145
+ associated = record.send(association.name)
146
+ unless associated.nil?
147
+ attrs[assoc_name] = serialize_attributes(associated, revisionable_associations[association.name], already_serialized)
148
+ else
149
+ attrs[assoc_name] = nil
150
+ end
151
+ elsif association.macro == :has_and_belongs_to_many
152
+ attrs[assoc_name] = record.send("#{association.name.to_s.singularize}_ids".to_sym)
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ return attrs
159
+ end
160
+
161
+ def attributes_and_associations (klass, hash)
162
+ attrs = {}
163
+ association_attrs = {}
164
+
165
+ if hash
166
+ hash.each_pair do |key, value|
167
+ if klass.reflections.include?(key.to_sym)
168
+ association_attrs[key] = value
169
+ else
170
+ attrs[key] = value
171
+ end
172
+ end
173
+ end
174
+
175
+ return [attrs, association_attrs]
176
+ end
177
+
178
+ def restore_association (record, association, association_attributes)
179
+ association = association.to_sym
180
+ reflection = record.class.reflections[association]
181
+ associated_record = nil
182
+ exists = false
183
+
184
+ begin
185
+ if reflection.macro == :has_many
186
+ if association_attributes.kind_of?(Array)
187
+ record.send("#{association}=".to_sym, [])
188
+ association_attributes.each do |attrs|
189
+ restore_association(record, association, attrs)
190
+ end
191
+ else
192
+ associated_record = record.send(association).build
193
+ associated_record.id = association_attributes['id']
194
+ exists = associated_record.class.find(associated_record.id) rescue nil
195
+ end
196
+ elsif reflection.macro == :has_one
197
+ associated_record = reflection.klass.new
198
+ associated_record.id = association_attributes['id']
199
+ exists = associated_record.class.find(associated_record.id) rescue nil
200
+ record.send("#{association}=", associated_record)
201
+ elsif reflection.macro == :has_and_belongs_to_many
202
+ record.send("#{association.to_s.singularize}_ids=", association_attributes)
203
+ end
204
+ rescue => e
205
+ record.errors.add(association, "could not be restored from the revision: #{e.message}")
206
+ end
207
+
208
+ return unless associated_record
209
+
210
+ attrs, association_attrs = attributes_and_associations(associated_record.class, association_attributes)
211
+ attrs.each_pair do |key, value|
212
+ begin
213
+ associated_record.send("#{key}=", value)
214
+ rescue
215
+ associated_record.errors.add(key.to_sym, "could not be restored to #{value.inspect}")
216
+ record.errors.add(association, "could not be restored from the revision") unless record.errors[association]
217
+ end
218
+ end
219
+
220
+ association_attrs.each_pair do |key, values|
221
+ restore_association(associated_record, key, values)
222
+ end
223
+
224
+ associated_record.instance_variable_set(:@new_record, nil) if exists
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,193 @@
1
+ require 'active_record'
2
+ require 'active_support/all'
3
+
4
+ module ActsAsRevisionable
5
+
6
+ autoload :RevisionRecord, File.expand_path('../acts_as_revisionable/revision_record', __FILE__)
7
+
8
+ def self.included (base)
9
+ base.extend(ActsMethods)
10
+ end
11
+
12
+ module ActsMethods
13
+ # Calling acts_as_revisionable will inject the revisionable behavior into the class. Specifying a :limit option
14
+ # will limit the number of revisions that are kept per record. Specifying :minimum_age will ensure that revisions are
15
+ # kept for at least a certain amount of time (i.e. 2.weeks). Associations to be revisioned can be specified with
16
+ # the :associations option as an array of association names. To specify associations of associations, use a hash
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 :tags, :comments, as well as the :ratings association on :comments:
19
+ #
20
+ # :associations => [:tags, {:comments => [:ratings]}]
21
+ #
22
+ # You can also pass an options of :on_update => true to automatically enable revisioning on every update.
23
+ # Otherwise you will need to perform your updates in a store_revision block. The reason for this is so that
24
+ # revisions for complex models with associations can be better controlled.
25
+ #
26
+ # A has_many :revision_records will also be added to the model for accessing the revisions.
27
+ def acts_as_revisionable (options = {})
28
+ write_inheritable_attribute(:acts_as_revisionable_options, options)
29
+ class_inheritable_reader(:acts_as_revisionable_options)
30
+ extend ClassMethods
31
+ include InstanceMethods
32
+ has_many_options = {:as => :revisionable, :order => 'revision DESC', :class_name => "ActsAsRevisionable::RevisionRecord"}
33
+ has_many_options[:dependent] = :destroy unless options[:dependent] == :keep
34
+ has_many :revision_records, has_many_options
35
+ alias_method_chain :update, :revision if options[:on_update]
36
+ end
37
+ end
38
+
39
+ module ClassMethods
40
+ # Load a revision for a record with a particular id. If this revision has association it
41
+ # will not delete associated records added since the revision was added if you save it.
42
+ # If you want to save a revision with associations properly, use restore_revision!
43
+ def restore_revision (id, revision)
44
+ revision = RevisionRecord.find_revision(self, id, revision)
45
+ return revision.restore if revision
46
+ end
47
+
48
+ # Load a revision for a record with a particular id and save it to the database. You should
49
+ # always use this method to save a revision if it has associations.
50
+ def restore_revision! (id, revision)
51
+ record = restore_revision(id, revision)
52
+ if record
53
+ record.store_revision do
54
+ save_restorable_associations(record, revisionable_associations)
55
+ end
56
+ end
57
+ return record
58
+ end
59
+
60
+ # Returns a hash structure used to identify the revisioned associations.
61
+ def revisionable_associations (options = acts_as_revisionable_options[:associations])
62
+ return nil unless options
63
+ options = [options] unless options.kind_of?(Array)
64
+ associations = {}
65
+ options.each do |association|
66
+ if association.kind_of?(Symbol)
67
+ associations[association] = true
68
+ elsif association.kind_of?(Hash)
69
+ association.each_pair do |key, value|
70
+ associations[key] = revisionable_associations(value)
71
+ end
72
+ end
73
+ end
74
+ return associations
75
+ end
76
+
77
+ private
78
+
79
+ def save_restorable_associations (record, associations)
80
+ record.class.transaction do
81
+ if associations.kind_of?(Hash)
82
+ associations.each_pair do |association, sub_associations|
83
+ associated_records = record.send(association)
84
+ reflection = record.class.reflections[association].macro
85
+
86
+ if reflection == :has_and_belongs_to_many
87
+ associated_records = associated_records.collect{|r| r}
88
+ record.send(association, true).clear
89
+ associated_records.each do |assoc_record|
90
+ record.send(association) << assoc_record
91
+ end
92
+ else
93
+ if reflection == :has_many
94
+ existing = associated_records.find(:all)
95
+ existing.each do |existing_association|
96
+ associated_records.delete(existing_association) unless associated_records.include?(existing_association)
97
+ end
98
+ end
99
+
100
+ associated_records = [associated_records] unless associated_records.kind_of?(Array)
101
+ associated_records.each do |associated_record|
102
+ save_restorable_associations(associated_record, sub_associations) if associated_record
103
+ end
104
+ end
105
+ end
106
+ end
107
+ record.save! unless record.new_record?
108
+ end
109
+ end
110
+ end
111
+
112
+ module InstanceMethods
113
+ # Restore a revision of the record and return it. The record is not saved to the database. If there
114
+ # is a problem restoring values, errors will be added to the record.
115
+ def restore_revision (revision)
116
+ self.class.restore_revision(self.id, revision)
117
+ end
118
+
119
+ # Restore a revision of the record and save it along with restored associations.
120
+ def restore_revision! (revision)
121
+ self.class.restore_revision!(self.id, revision)
122
+ end
123
+
124
+ # Call this method to implement revisioning. The object changes should happen inside the block.
125
+ def store_revision
126
+ if new_record? or @revisions_disabled
127
+ return yield
128
+ else
129
+ retval = nil
130
+ revision = nil
131
+ begin
132
+ RevisionRecord.transaction do
133
+ read_only = self.class.find(self.id, :readonly => true) rescue nil
134
+ if read_only
135
+ revision = read_only.create_revision!
136
+ truncate_revisions!
137
+ end
138
+
139
+ disable_revisioning do
140
+ retval = yield
141
+ end
142
+
143
+ raise 'rollback_revision' unless errors.empty?
144
+ end
145
+ rescue => e
146
+ # In case the database doesn't support transactions
147
+ if revision
148
+ revision.destroy rescue nil
149
+ end
150
+ raise e unless e.message == 'rollback_revision'
151
+ end
152
+ return retval
153
+ end
154
+ end
155
+
156
+ # Create a revision record based on this record and save it to the database.
157
+ def create_revision!
158
+ revision = RevisionRecord.new(self, acts_as_revisionable_options[:encoding])
159
+ revision.save!
160
+ return revision
161
+ end
162
+
163
+ # Truncate the number of revisions kept for this record. Available options are :limit and :minimum_age.
164
+ def truncate_revisions! (options = nil)
165
+ options = {:limit => acts_as_revisionable_options[:limit], :minimum_age => acts_as_revisionable_options[:minimum_age]} unless options
166
+ RevisionRecord.truncate_revisions(self.class, self.id, options)
167
+ end
168
+
169
+ # Disable the revisioning behavior inside of a block passed to the method.
170
+ def disable_revisioning
171
+ save_val = @revisions_disabled
172
+ retval = nil
173
+ begin
174
+ @revisions_disabled = true
175
+ retval = yield if block_given?
176
+ ensure
177
+ @revisions_disabled = save_val
178
+ end
179
+ return retval
180
+ end
181
+
182
+ private
183
+
184
+ # This is the update call that overrides the default update method.
185
+ def update_with_revision
186
+ store_revision do
187
+ update_without_revision
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ ActiveRecord::Base.send(:include, ActsAsRevisionable)