add has many associations functionality

This commit is contained in:
lyfeyaj 2014-02-25 22:02:36 +08:00
parent 214433575f
commit 78292e0fb0
8 changed files with 209 additions and 36 deletions

2
.gitignore vendored
View File

@ -14,3 +14,5 @@ Gemfile.lock
vendor/*
.idea
.rvmrc
.tags
.tags_sorted_by_file

View File

@ -12,12 +12,23 @@ module PaperTrail
desc 'Generates (but does not run) a migration to add a versions table.'
def create_migration_file
migration_template 'create_versions.rb', 'db/migrate/create_versions.rb'
migration_template 'add_object_changes_column_to_versions.rb', 'db/migrate/add_object_changes_column_to_versions.rb' if options.with_changes?
add_paper_trail_migration('create_versions')
add_paper_trail_migration('add_object_changes_column_to_versions') if options.with_changes?
add_paper_trail_migration('create_version_associations')
add_paper_trail_migration('add_transaction_id_column_to_versions')
end
def self.next_migration_number(dirname)
::ActiveRecord::Generators::Base.next_migration_number(dirname)
end
protected
def add_paper_trail_migration(template)
migration_dir = File.expand_path('db/migrate')
if !self.class.migration_exists?(migration_dir, template)
migration_template "#{template}.rb", "db/migrate/#{template}.rb"
end
end
end
end

View File

@ -0,0 +1,11 @@
class AddTransactionIdColumnToVersions < ActiveRecord::Migration
def self.up
add_column :versions, :transaction_id, :integer
add_index :versions, [:transaction_id]
end
def self.down
remove_column :versions, :transaction_id
remove_index :versions, [:transaction_id]
end
end

View File

@ -0,0 +1,17 @@
class CreateVersionAssociations < ActiveRecord::Migration
def self.up
create_table :version_associations do |t|
t.integer :version_id
t.string :foreign_key_name, :null => false
t.integer :foreign_key_id
end
add_index :version_associations, [:version_id]
add_index :version_associations, [:foreign_key_name, :foreign_key_id], :name => 'index_on_foreign_key_name_and foreign_key_id'
end
def self.down
remove_index :version_associations, [:version_id]
remove_index :version_associations, [:foreign_key_name, :foreign_key_id], :name => 'index_on_foreign_key_name_and foreign_key_id'
drop_table :version_associations
end
end

View File

@ -92,6 +92,28 @@ module PaperTrail
@active_record_protected_attributes ||= ::ActiveRecord::VERSION::MAJOR < 4 || !!defined?(ProtectedAttributes)
end
def self.transaction?
ActiveRecord::Base.connection.open_transactions > 0 || paper_trail_store[:transaction_open]
end
def self.start_transaction
paper_trail_store[:transaction_open] = true
self.transaction_id = nil
end
def self.end_transaction
paper_trail_store[:transaction_open] = false
self.transaction_id = nil
end
def self.transaction_id
paper_trail_store[:transaction_id]
end
def self.transaction_id=(id)
paper_trail_store[:transaction_id] = id
end
private
# Thread-safe hash to hold PaperTrail's data.
@ -119,6 +141,7 @@ unless PaperTrail.active_record_protected_attributes?
end
require 'paper_trail/version'
require 'paper_trail/version_association'
# Require frameworks
require 'paper_trail/frameworks/rails'

View File

@ -14,11 +14,11 @@ module PaperTrail
# `:create`, `:update`, `:destroy` as desired.
# :class_name the name of a custom Version class. This class should inherit from `PaperTrail::Version`.
# :ignore an array of attributes for which a new `Version` will not be created if only they change.
# it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`),
# it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`),
# which will only be ignored if the value is a `Proc` which returns truthily.
# :if, :unless Procs that allow to specify conditions when to save versions for an object
# :only inverse of `ignore` - a new `Version` will be created only for these attributes if supplied
# it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`),
# it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`),
# which will only be counted if the value is a `Proc` which returns truthily.
# :skip fields to ignore completely. As with `ignore`, updates to these fields will not create
# a new `Version`. In addition, these fields will not be included in the serialized versions
@ -74,6 +74,10 @@ module PaperTrail
after_create :record_create, :if => :save_version? if options_on.empty? || options_on.include?(:create)
before_update :record_update, :if => :save_version? if options_on.empty? || options_on.include?(:update)
after_destroy :record_destroy, :if => :save_version? if options_on.empty? || options_on.include?(:destroy)
# Reset the transaction id when the transaction is closed
after_commit :reset_transaction_id
after_rollback :reset_transaction_id
end
# Switches PaperTrail off for this class.
@ -91,7 +95,7 @@ module PaperTrail
PaperTrail.enabled_for_model(self, true)
end
def paper_trail_on
def paper_trail_on
warn "DEPRECATED: use `paper_trail_on!` instead of `paper_trail_on`. Support for `paper_trail_on` will be removed in PaperTrail 3.1"
self.paper_trail_on!
end
@ -216,6 +220,16 @@ module PaperTrail
self.class.paper_trail_on! if paper_trail_was_enabled
end
# Utility method for reifying. Anything executed inside the block will appear like a new record
def appear_as_new_record
instance_eval {
alias :old_new_record? :new_record?
alias :new_record? :present?
}
yield
instance_eval { alias :new_record? :old_new_record? }
end
# Mimicks behavior of `touch` method from `ActiveRecord::Persistence`, but generates a version
#
# TODO: lookinto leveraging the `after_touch` callback from `ActiveRecord` to allow the
@ -241,15 +255,18 @@ module PaperTrail
def record_create
if paper_trail_switched_on?
data = {
:event => paper_trail_event || 'create',
:whodunnit => PaperTrail.whodunnit
:event => paper_trail_event || 'create',
:whodunnit => PaperTrail.whodunnit,
:transaction_id => PaperTrail.transaction_id
}
if changed_notably? and self.class.paper_trail_version_class.column_names.include?('object_changes')
data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail :
PaperTrail.serializer.dump(changes_for_paper_trail)
end
send(self.class.versions_association_name).create! merge_metadata(data)
version = send(self.class.versions_association_name).create! merge_metadata(data)
set_transaction_id(version)
save_associations(version)
end
end
@ -257,15 +274,18 @@ module PaperTrail
if paper_trail_switched_on? && changed_notably?
object_attrs = object_attrs_for_paper_trail(item_before_change)
data = {
:event => paper_trail_event || 'update',
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
:whodunnit => PaperTrail.whodunnit
:event => paper_trail_event || 'update',
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
:whodunnit => PaperTrail.whodunnit,
:transaction_id => PaperTrail.transaction_id
}
if self.class.paper_trail_version_class.column_names.include?('object_changes')
data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail :
PaperTrail.serializer.dump(changes_for_paper_trail)
end
send(self.class.versions_association_name).build merge_metadata(data)
version = send(self.class.versions_association_name).build merge_metadata(data)
set_transaction_id(version)
save_associations(version)
end
end
@ -279,17 +299,38 @@ module PaperTrail
if paper_trail_switched_on? and not new_record?
object_attrs = object_attrs_for_paper_trail(item_before_change)
data = {
:item_id => self.id,
:item_type => self.class.base_class.name,
:event => paper_trail_event || 'destroy',
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
:whodunnit => PaperTrail.whodunnit
:item_id => self.id,
:item_type => self.class.base_class.name,
:event => paper_trail_event || 'destroy',
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
:whodunnit => PaperTrail.whodunnit,
:transaction_id => PaperTrail.transaction_id
}
self.class.paper_trail_version_class.create merge_metadata(data)
version = self.class.paper_trail_version_class.create merge_metadata(data)
send(self.class.versions_association_name).send :load_target
set_transaction_id(version)
save_associations(version)
end
end
def save_associations(version)
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
PaperTrail::VersionAssociation.create(:version_id => version.id, :foreign_key_name => assoc.foreign_key, :foreign_key_id => self.send(assoc.foreign_key))
end
end
def set_transaction_id(version)
if PaperTrail.transaction? && PaperTrail.transaction_id.nil?
PaperTrail.transaction_id = version.id
version.transaction_id = version.id
version.save
end
end
def reset_transaction_id
PaperTrail.transaction_id = nil
end
def merge_metadata(data)
# First we merge the model-level metadata in `meta`.
paper_trail_options[:meta].each do |k,v|

View File

@ -0,0 +1,7 @@
module PaperTrail
class VersionAssociation < ActiveRecord::Base
belongs_to :version
attr_accessible :version_id, :foreign_key_name, :foreign_key_id if PaperTrail.active_record_protected_attributes?
end
end

View File

@ -6,10 +6,14 @@ module PaperTrail
included do
belongs_to :item, :polymorphic => true
has_many :version_associations, :dependent => :destroy
validates_presence_of :event
attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes if PaperTrail.active_record_protected_attributes?
attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes, :transaction_id if PaperTrail.active_record_protected_attributes?
after_create :enforce_version_limit!
scope :within_transaction, lambda { |id| where :transaction_id => id }
end
module ClassMethods
@ -77,8 +81,11 @@ module PaperTrail
return nil if object.nil?
without_identity_map do
options[:has_one] = 3 if options[:has_one] == true
options.reverse_merge! :has_one => false
options.reverse_merge!(
:version_at => created_at,
:has_one => false,
:has_many => false
)
attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
@ -119,8 +126,16 @@ module PaperTrail
model.send "#{model.class.version_association_name}=", self
# unless options[:has_one] == false
# reify_has_ones model, options[:has_one]
# end
unless options[:has_one] == false
reify_has_ones model, options[:has_one]
reify_has_ones model, options
end
unless options[:has_many] == false
reify_has_manys model, options
end
model
@ -140,6 +155,15 @@ module PaperTrail
{}
end
# Rollback all changes within a transaction
def rollback
transaction do
self.class.within_transaction(transaction_id).reverse_each do |version|
version.reify.save!
end
end
end
# Returns who put the item into the state stored in this version.
def originator
@originator ||= previous.whodunnit rescue nil
@ -187,28 +211,65 @@ module PaperTrail
# Restore the `model`'s has_one associations as they were when this version was
# superseded by the next (because that's what the user was looking at when they
# made the change).
#
# The `lookback` sets how many seconds before the model's change we go.
def reify_has_ones(model, lookback)
def reify_has_ones(model, options = {})
version_table_name = model.class.paper_trail_version_class.table_name
model.class.reflect_on_all_associations(:has_one).each do |assoc|
child = model.send assoc.name
if child.respond_to? :version_at
# N.B. we use version of the child as it was `lookback` seconds before the parent was updated.
# Ideally we want the version of the child as it was just before the parent was updated...
# but until PaperTrail knows which updates are "together" (e.g. parent and child being
# updated on the same form), it's impossible to tell when the overall update started;
# and therefore impossible to know when "just before" was.
if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds))
child_as_it_was.attributes.each do |k,v|
model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil
version = model.class.paper_trail_version_class.joins(:version_associations).
where(:item_type => assoc.class_name).
where(:foreign_key_name => assoc.foreign_key).
where(:foreign_key_id => model.id).
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
order("#{version_table_name}.id ASC").first
if version
if version.event == 'create'
if child = version.item
child.mark_for_destruction
model.send "#{assoc.name}=", nil
end
else
model.send "#{assoc.name}=", nil
child = version.reify options
logger.info "Reify #{child}"
model.appear_as_new_record do
model.send(model.send "#{assoc.name}=", child)
end
end
end
end
end
# Restore the `model`'s has_many associations as they were at version_at timestamp
# We lookup the first child versions after version_at timestamp or in same transaction.
def reify_has_manys(model, options = {})
version_table_name = model.class.paper_trail_version_class.table_name
model.class.reflect_on_all_associations(:has_many).each do |assoc|
next if assoc.name == model.class.versions_association_name
version_id_subquery = PaperTrail::VersionAssociation.joins(model.class.version_association_name).
select("MIN(version_id)").
where("#{version_table_name}.item_type = ?", assoc.class_name).
where(:foreign_key_name => assoc.foreign_key).
where(:foreign_key_id => model.id).
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
group("item_id").to_sql
versions = model.class.where("id IN (#{version_id_subquery})")
# Pass true to force the model to load
collection = Array.new model.send(assoc.name, true)
versions.each do |version|
if version.event == 'create'
if child = version.item
collection.delete child
end
else
child = version.reify(options)
collection.map!{ |c| c.id == child.id ? child : c }
end
end
model.send assoc.name, collection
end
end
# checks to see if a value has been set for the `version_limit` config option, and if so enforces it
def enforce_version_limit!
return unless PaperTrail.config.version_limit.is_a? Numeric