add has many associations functionality
This commit is contained in:
parent
214433575f
commit
78292e0fb0
|
@ -14,3 +14,5 @@ Gemfile.lock
|
||||||
vendor/*
|
vendor/*
|
||||||
.idea
|
.idea
|
||||||
.rvmrc
|
.rvmrc
|
||||||
|
.tags
|
||||||
|
.tags_sorted_by_file
|
|
@ -12,12 +12,23 @@ module PaperTrail
|
||||||
desc 'Generates (but does not run) a migration to add a versions table.'
|
desc 'Generates (but does not run) a migration to add a versions table.'
|
||||||
|
|
||||||
def create_migration_file
|
def create_migration_file
|
||||||
migration_template 'create_versions.rb', 'db/migrate/create_versions.rb'
|
add_paper_trail_migration('create_versions')
|
||||||
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('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
|
end
|
||||||
|
|
||||||
def self.next_migration_number(dirname)
|
def self.next_migration_number(dirname)
|
||||||
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -92,6 +92,28 @@ module PaperTrail
|
||||||
@active_record_protected_attributes ||= ::ActiveRecord::VERSION::MAJOR < 4 || !!defined?(ProtectedAttributes)
|
@active_record_protected_attributes ||= ::ActiveRecord::VERSION::MAJOR < 4 || !!defined?(ProtectedAttributes)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
# Thread-safe hash to hold PaperTrail's data.
|
# Thread-safe hash to hold PaperTrail's data.
|
||||||
|
@ -119,6 +141,7 @@ unless PaperTrail.active_record_protected_attributes?
|
||||||
end
|
end
|
||||||
|
|
||||||
require 'paper_trail/version'
|
require 'paper_trail/version'
|
||||||
|
require 'paper_trail/version_association'
|
||||||
|
|
||||||
# Require frameworks
|
# Require frameworks
|
||||||
require 'paper_trail/frameworks/rails'
|
require 'paper_trail/frameworks/rails'
|
||||||
|
|
|
@ -14,11 +14,11 @@ module PaperTrail
|
||||||
# `:create`, `:update`, `:destroy` as desired.
|
# `:create`, `:update`, `:destroy` as desired.
|
||||||
# :class_name the name of a custom Version class. This class should inherit from `PaperTrail::Version`.
|
# :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.
|
# :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.
|
# 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
|
# :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
|
# :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.
|
# 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
|
# :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
|
# 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)
|
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)
|
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)
|
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
|
end
|
||||||
|
|
||||||
# Switches PaperTrail off for this class.
|
# Switches PaperTrail off for this class.
|
||||||
|
@ -91,7 +95,7 @@ module PaperTrail
|
||||||
PaperTrail.enabled_for_model(self, true)
|
PaperTrail.enabled_for_model(self, true)
|
||||||
end
|
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"
|
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!
|
self.paper_trail_on!
|
||||||
end
|
end
|
||||||
|
@ -216,6 +220,16 @@ module PaperTrail
|
||||||
self.class.paper_trail_on! if paper_trail_was_enabled
|
self.class.paper_trail_on! if paper_trail_was_enabled
|
||||||
end
|
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
|
# Mimicks behavior of `touch` method from `ActiveRecord::Persistence`, but generates a version
|
||||||
#
|
#
|
||||||
# TODO: lookinto leveraging the `after_touch` callback from `ActiveRecord` to allow the
|
# TODO: lookinto leveraging the `after_touch` callback from `ActiveRecord` to allow the
|
||||||
|
@ -241,15 +255,18 @@ module PaperTrail
|
||||||
def record_create
|
def record_create
|
||||||
if paper_trail_switched_on?
|
if paper_trail_switched_on?
|
||||||
data = {
|
data = {
|
||||||
:event => paper_trail_event || 'create',
|
:event => paper_trail_event || 'create',
|
||||||
:whodunnit => PaperTrail.whodunnit
|
:whodunnit => PaperTrail.whodunnit,
|
||||||
|
:transaction_id => PaperTrail.transaction_id
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed_notably? and self.class.paper_trail_version_class.column_names.include?('object_changes')
|
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 :
|
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)
|
PaperTrail.serializer.dump(changes_for_paper_trail)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -257,15 +274,18 @@ module PaperTrail
|
||||||
if paper_trail_switched_on? && changed_notably?
|
if paper_trail_switched_on? && changed_notably?
|
||||||
object_attrs = object_attrs_for_paper_trail(item_before_change)
|
object_attrs = object_attrs_for_paper_trail(item_before_change)
|
||||||
data = {
|
data = {
|
||||||
:event => paper_trail_event || 'update',
|
:event => paper_trail_event || 'update',
|
||||||
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
|
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
|
||||||
:whodunnit => PaperTrail.whodunnit
|
:whodunnit => PaperTrail.whodunnit,
|
||||||
|
:transaction_id => PaperTrail.transaction_id
|
||||||
}
|
}
|
||||||
if self.class.paper_trail_version_class.column_names.include?('object_changes')
|
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 :
|
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)
|
PaperTrail.serializer.dump(changes_for_paper_trail)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -279,17 +299,38 @@ module PaperTrail
|
||||||
if paper_trail_switched_on? and not new_record?
|
if paper_trail_switched_on? and not new_record?
|
||||||
object_attrs = object_attrs_for_paper_trail(item_before_change)
|
object_attrs = object_attrs_for_paper_trail(item_before_change)
|
||||||
data = {
|
data = {
|
||||||
:item_id => self.id,
|
:item_id => self.id,
|
||||||
:item_type => self.class.base_class.name,
|
:item_type => self.class.base_class.name,
|
||||||
:event => paper_trail_event || 'destroy',
|
:event => paper_trail_event || 'destroy',
|
||||||
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
|
:object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
|
||||||
:whodunnit => PaperTrail.whodunnit
|
: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
|
send(self.class.versions_association_name).send :load_target
|
||||||
|
set_transaction_id(version)
|
||||||
|
save_associations(version)
|
||||||
end
|
end
|
||||||
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)
|
def merge_metadata(data)
|
||||||
# First we merge the model-level metadata in `meta`.
|
# First we merge the model-level metadata in `meta`.
|
||||||
paper_trail_options[:meta].each do |k,v|
|
paper_trail_options[:meta].each do |k,v|
|
||||||
|
|
|
@ -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
|
|
@ -6,10 +6,14 @@ module PaperTrail
|
||||||
|
|
||||||
included do
|
included do
|
||||||
belongs_to :item, :polymorphic => true
|
belongs_to :item, :polymorphic => true
|
||||||
|
has_many :version_associations, :dependent => :destroy
|
||||||
|
|
||||||
validates_presence_of :event
|
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!
|
after_create :enforce_version_limit!
|
||||||
|
|
||||||
|
scope :within_transaction, lambda { |id| where :transaction_id => id }
|
||||||
end
|
end
|
||||||
|
|
||||||
module ClassMethods
|
module ClassMethods
|
||||||
|
@ -77,8 +81,11 @@ module PaperTrail
|
||||||
return nil if object.nil?
|
return nil if object.nil?
|
||||||
|
|
||||||
without_identity_map do
|
without_identity_map do
|
||||||
options[:has_one] = 3 if options[:has_one] == true
|
options.reverse_merge!(
|
||||||
options.reverse_merge! :has_one => false
|
:version_at => created_at,
|
||||||
|
:has_one => false,
|
||||||
|
:has_many => false
|
||||||
|
)
|
||||||
|
|
||||||
attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
|
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
|
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
|
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
|
end
|
||||||
|
|
||||||
model
|
model
|
||||||
|
@ -140,6 +155,15 @@ module PaperTrail
|
||||||
{}
|
{}
|
||||||
end
|
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.
|
# Returns who put the item into the state stored in this version.
|
||||||
def originator
|
def originator
|
||||||
@originator ||= previous.whodunnit rescue nil
|
@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
|
# 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
|
# superseded by the next (because that's what the user was looking at when they
|
||||||
# made the change).
|
# made the change).
|
||||||
#
|
def reify_has_ones(model, options = {})
|
||||||
# The `lookback` sets how many seconds before the model's change we go.
|
version_table_name = model.class.paper_trail_version_class.table_name
|
||||||
def reify_has_ones(model, lookback)
|
|
||||||
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
||||||
child = model.send assoc.name
|
version = model.class.paper_trail_version_class.joins(:version_associations).
|
||||||
if child.respond_to? :version_at
|
where(:item_type => assoc.class_name).
|
||||||
# N.B. we use version of the child as it was `lookback` seconds before the parent was updated.
|
where(:foreign_key_name => assoc.foreign_key).
|
||||||
# Ideally we want the version of the child as it was just before the parent was updated...
|
where(:foreign_key_id => model.id).
|
||||||
# but until PaperTrail knows which updates are "together" (e.g. parent and child being
|
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
||||||
# updated on the same form), it's impossible to tell when the overall update started;
|
order("#{version_table_name}.id ASC").first
|
||||||
# and therefore impossible to know when "just before" was.
|
if version
|
||||||
if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds))
|
if version.event == 'create'
|
||||||
child_as_it_was.attributes.each do |k,v|
|
if child = version.item
|
||||||
model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil
|
child.mark_for_destruction
|
||||||
|
model.send "#{assoc.name}=", nil
|
||||||
end
|
end
|
||||||
else
|
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
|
||||||
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
|
# checks to see if a value has been set for the `version_limit` config option, and if so enforces it
|
||||||
def enforce_version_limit!
|
def enforce_version_limit!
|
||||||
return unless PaperTrail.config.version_limit.is_a? Numeric
|
return unless PaperTrail.config.version_limit.is_a? Numeric
|
||||||
|
|
Loading…
Reference in New Issue