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 +1 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +56 -0
- data/Rakefile +47 -0
- data/VERSION +1 -0
- data/acts_as_revisionable.gemspec +66 -0
- data/lib/acts_as_revisionable/revision_record.rb +227 -0
- data/lib/acts_as_revisionable.rb +193 -0
- data/spec/acts_as_revisionable_spec.rb +176 -0
- data/spec/full_spec.rb +448 -0
- data/spec/revision_record_spec.rb +428 -0
- data/spec/spec_helper.rb +23 -0
- metadata +141 -0
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)
|