Module: ActiveRecord::Acts::Versioned::ClassMethods

Defined in:
lib/rtiss_acts_as_versioned.rb

Instance Method Summary collapse

Instance Method Details

#acts_as_versioned(options = {}, &extension) ⇒ Object

Configuration options

  • class_name - versioned model class name (default: PageVersion in the above example)

  • table_name - versioned model table name (default: page_versions in the above example)

  • foreign_key - foreign key used to relate the versioned model to the original model (default: page_id in the above example)

  • inheritance_column - name of the column to save the model’s inheritance_column value for STI. (default: versioned_type)

  • version_column - name of the column in the model that keeps the version number (default: version)

  • sequence_name - name of the custom sequence to be used by the versioned model.

  • limit - number of revisions to keep, defaults to unlimited

  • if - symbol of method to check before saving a new version. If this method returns false, a new version is not saved. For finer control, pass either a Proc or modify Model#version_condition_met?

    acts_as_versioned :if => Proc.new { |auction| !auction.expired? }
    

    or…

    class Auction
      def version_condition_met? # totally bypasses the <tt>:if</tt> option
        !expired?
      end
    end
    
  • if_changed - Simple way of specifying attributes that are required to be changed before saving a model. This takes either a symbol or array of symbols.

  • extend - Lets you specify a module to be mixed in both the original and versioned models. You can also just pass a block to create an anonymous mixin:

    class Auction
      acts_as_versioned do
        def started?
          !started_at.nil?
        end
      end
    end
    

    or…

    module AuctionExtension
      def started?
        !started_at.nil?
      end
    end
    class Auction
      acts_as_versioned :extend => AuctionExtension
    end
    
Example code:

  @auction = Auction.find(1)
  @auction.started?
  @auction.versions.first.started?

Database Schema

The model that you’re versioning needs to have a ‘version’ attribute. The model is versioned into a table called #model_versions where the model name is singlular. The _versions table should contain all the fields you want versioned, the same version column, and a #model_id foreign key field.

A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance, then that field is reflected in the versioned model as ‘versioned_type’ by default.

Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table method, perfect for a migration. It will also create the version column if the main model does not already have it.

class AddVersions < ActiveRecord::Migration
  def self.up
    # create_versioned_table takes the same options hash
    # that create_table does
    Post.create_versioned_table
  end

  def self.down
    Post.drop_versioned_table
  end
end

Changing What Fields Are Versioned

By default, acts_as_versioned will version all but these fields:

[self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]

You can add or change those by modifying #non_versioned_columns. Note that this takes strings and not symbols.

class Post < ActiveRecord::Base
  acts_as_versioned
  self.non_versioned_columns << 'comments_count'
end


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
306
307
308
309
310
311
# File 'lib/rtiss_acts_as_versioned.rb', line 168

def acts_as_versioned(options = {}, &extension)
  # don't allow multiple calls
  return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods)

  send :include, ActiveRecord::Acts::Versioned::ActMethods

  cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column, 
    :version_column, :max_version_limit, :track_altered_attributes, :version_condition, :version_sequence_name, :non_versioned_columns,
    :version_association_options, :version_if_changed, :deleted_in_original_table_flag, :record_restored_column

  self.versioned_class_name         = options[:class_name]  || "Version"
  self.versioned_foreign_key        = options[:foreign_key] || self.to_s.foreign_key
  self.versioned_table_name         = options[:table_name]  || if self.table_name then "#{table_name}_h" else "#{table_name_prefix}#{base_class.name.demodulize.underscore}_h#{table_name_suffix}" end

  self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
  self.version_column               = options[:version_column]     || 'version'
  self.deleted_in_original_table_flag = options[:deleted_in_original_table_flag]     || 'deleted_in_original_table'
  self.record_restored_column       = options[:record_restored_column]     || 'record_restored'
  self.version_sequence_name        = options[:sequence_name]
  self.max_version_limit            = options[:limit].to_i
  self.version_condition            = options[:if] || true
  self.non_versioned_columns        = [self.primary_key, inheritance_column, self.version_column, 'lock_version', versioned_inheritance_column] + options[:non_versioned_columns].to_a.map(&:to_s)
  if options[:association_options].is_a?(Hash) && options[:association_options][:dependent] == :nullify
    raise "Illegal option :dependent => :nullify - this would produce orphans in version model"
  end
  self.version_association_options  = {
                                        :class_name  => "#{self.to_s}::#{versioned_class_name}",
                                        :foreign_key => versioned_foreign_key
                                      }.merge(options[:association_options] || {})

  if block_given?
    extension_module_name = "#{versioned_class_name}Extension"
    silence_warnings do
      self.const_set(extension_module_name, Module.new(&extension))
    end

    options[:extend] = self.const_get(extension_module_name)
  end

  class_eval <<-CLASS_METHODS, __FILE__, __LINE__ + 1
    has_many :versions, version_association_options do
      # finds earliest version of this record
      def earliest
        @earliest ||= order('#{version_column}').first
      end

      # find latest version of this record
      def latest
        @latest ||= order('#{version_column} desc').first
      end
    end
    before_save  :set_new_version
    after_save   :save_version
    after_save   :clear_old_versions
    after_destroy :set_deleted_flag

    unless options[:if_changed].nil?
      self.track_altered_attributes = true
      options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array)
      self.version_if_changed = options[:if_changed].map(&:to_s)
    end

    include options[:extend] if options[:extend].is_a?(Module)
  CLASS_METHODS

  # create the dynamic versioned model
  const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do
    def self.reloadable? ; false ; end
    # find first version before the given version
    # TODO: replace "version" in selects with version_column, use select-method instead of find
    def self.before(version)
      where("#{original_class.versioned_foreign_key} = ? and version < ?", version.send(original_class.versioned_foreign_key), version.version).order('version desc').first
    end

    # find first version after the given version.
    def self.after(version)
      where("#{original_class.versioned_foreign_key} = ? and version > ?", version.send(original_class.versioned_foreign_key), version.version).order('version').first
    end

    def previous
      self.class.before(self)
    end

    def next
      self.class.after(self)
    end

    def versions_count
      page.version # TODO: ?!
    end

    def restore(perform_validation = true)
      id = self.send(self.original_class.versioned_foreign_key)
      if self.original_class.exists?(id)
        raise RuntimeError.new("Record exists in restore, id = #{id} class = #{self.class.name}")
      end

      version_hash = self.attributes
      version_hash.delete "id"
      version_hash.delete self.original_class.deleted_in_original_table_flag.to_s
      version_hash.delete self.original_class.record_restored_column.to_s
      version_hash.delete self.original_class.versioned_foreign_key.to_s

      restored_record = self.original_class.new(version_hash)
      restored_record.id = id
      if restored_record.respond_to? :updated_at=
        restored_record.updated_at = Time.now
      end
      # DON'T EVEN THINK ABOUT CALCULATING THE VERSION NUMBER USING THE VERSIONS ASSOCIATION HERE:
      # There is a problem in ActiveRecord. An association Relation will be converted to an Array internally, when the SQL-select is
      # executed.
      # Some ActiveRecord-Methods (for example #ActiveRecord::Base::AutosaveAssociation#save_collection_association) try to use ActiveRecord methods
      # with these Relations, and if these Relations have been converted to Arrays, these calls fail with an Exception
      new_version_number = self.class.where(self.original_class.versioned_foreign_key => id).order('id desc').first.send(restored_record.version_column).to_i + 1
      restored_record.send("#{restored_record.version_column}=", new_version_number)
      unless restored_record.save_without_revision(perform_validation)
        raise RuntimeError.new("Couldn't restore the record, id = #{id} class = #{self.class.name}")
      end
      restored_record.save_version(true, false, self.send(self.original_class.version_column))
    end

    def record_restored?
      self.read_attribute(self.original_class.record_restored_column) != nil
    end
    alias :record_restored :record_restored?

    def record_restored_from_version
      self.read_attribute(self.original_class.record_restored_column)
    end

    def original_record_exists?
      original_class.exists?(self.send original_class.versioned_foreign_key)
    end
  end

  versioned_class.cattr_accessor :original_class
  versioned_class.original_class = self
  versioned_class.table_name = versioned_table_name
  versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym, 
    :class_name  => "::#{self.to_s}", 
    :foreign_key => versioned_foreign_key
  versioned_class.send :include, options[:extend]         if options[:extend].is_a?(Module)
  versioned_class.sequence_name = version_sequence_name if version_sequence_name
end