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.
@@ -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 :limit option to the acts_as_revisionable statement:
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 :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:
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 :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:
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, 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.
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
- 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.
62
+ record = Model.find(100)
63
+ record.destroy
64
+ restored = Model.restore_last_revision!(100)
@@ -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 'rake/rdoctask'
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.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.6
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.6"
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-02-08}
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.4.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.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.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.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"])
@@ -1,11 +1,11 @@
1
1
  require 'active_record'
2
- require 'active_support/all'
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 (base)
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 :tags, :comments, as well as the :ratings association on :comments:
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 :on_update => true to automatically enable revisioning on every update.
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 (options = {})
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
- # 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.
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 (id, revision)
44
- revision = RevisionRecord.find_revision(self, id, revision)
45
- return revision.restore if revision
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! (id, revision)
51
- record = restore_revision(id, revision)
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 (options = acts_as_revisionable_options[: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 (record, 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.find(:all)
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 (revision)
116
- self.class.restore_revision(self.id, revision)
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! (revision)
121
- self.class.restore_revision!(self.id, revision)
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? or @revisions_disabled
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
- read_only = self.class.find(self.id, :readonly => true) rescue nil
134
- if read_only
135
- revision = read_only.create_revision!
136
- truncate_revisions!
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 'rollback_revision' unless errors.empty?
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
- revision.destroy rescue nil
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 unless e.message == 'rollback_revision'
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! (options = nil)
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
- # This is the update call that overrides the default update method.
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 (klass, id, 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 (revisionable_type, revisionable_id, options)
20
- return unless options[:limit] or options[:minimum_age]
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 == "MySQL" ? 5.megabytes : nil)
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
- connection.add_index :revision_records, [:revisionable_type, :revisionable_id, :revision], :name => "revisionable", :unique => true
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 (record, encoding = :ruby)
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
- 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) 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 (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 (data)
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 (record, revisionable_associations, already_serialized = {})
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".to_sym)
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 (klass, hash)
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 (record, association, association_attributes)
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
- exists = false
185
-
203
+
186
204
  begin
187
205
  if reflection.macro == :has_many
188
206
  if association_attributes.kind_of?(Array)
189
- record.send("#{association}=".to_sym, [])
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.id = association_attributes['id']
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.id = association_attributes['id']
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
- return unless associated_record
211
-
212
- attrs, association_attrs = attributes_and_associations(associated_record.class, association_attributes)
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
- associated_record.send("#{key}=", value)
242
+ record.send("#{key}=", value)
216
243
  rescue
217
- associated_record.errors.add(key.to_sym, "could not be restored to #{value.inspect}")
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(associated_record, key, values)
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
- associated_record.instance_variable_set(:@new_record, nil) if associated_record.instance_variable_defined?(:@new_record)
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
- associated_record.instance_variable_set(:@persisted, true) if associated_record.instance_variable_defined?(:@persisted)
259
+ record.instance_variable_set(:@persisted, true) if record.instance_variable_defined?(:@persisted)
230
260
  end
231
261
  end
232
262
  end