lib/mongoid/history/tracker.rb
module Mongoid
module History
module Tracker
extend ActiveSupport::Concern
included do
include Mongoid::Document
include Mongoid::Timestamps
attr_writer :trackable
field :association_chain, type: Array, default: []
field :modified, type: Hash, default: {}
field :original, type: Hash, default: {}
field :version, type: Integer
field :action, type: String
field :scope, type: String
modifier_options = {
class_name: Mongoid::History.modifier_class_name
}
modifier_options[:optional] = true if Mongoid::Compatibility::Version.mongoid6_or_newer?
belongs_to :modifier, modifier_options
index(scope: 1)
index(association_chain: 1)
Mongoid::History.tracker_class_name ||= name.tableize.singularize.to_sym
end
def undo!(modifier = nil)
if action.to_sym == :destroy
re_create
elsif action.to_sym == :create
re_destroy
elsif Mongoid::Compatibility::Version.mongoid3?
trackable.update_attributes!(undo_attr(modifier), without_protection: true)
else
trackable.update_attributes!(undo_attr(modifier))
end
end
def redo!(modifier = nil)
if action.to_sym == :destroy
re_destroy
elsif action.to_sym == :create
re_create
elsif Mongoid::Compatibility::Version.mongoid3?
trackable.update_attributes!(redo_attr(modifier), without_protection: true)
else
trackable.update_attributes!(redo_attr(modifier))
end
end
def undo_attr(modifier)
undo_hash = affected.easy_unmerge(modified)
undo_hash.easy_merge!(original)
modifier_field = trackable.history_trackable_options[:modifier_field]
undo_hash[modifier_field] = modifier if modifier_field
(modified.keys - undo_hash.keys).each do |k|
undo_hash[k] = nil
end
localize_keys(undo_hash)
end
def redo_attr(modifier)
redo_hash = affected.easy_unmerge(original)
redo_hash.easy_merge!(modified)
modifier_field = trackable.history_trackable_options[:modifier_field]
redo_hash[modifier_field] = modifier if modifier_field
localize_keys(redo_hash)
end
def trackable_root
@trackable_root ||= trackable_parents_and_trackable.first
end
def trackable
@trackable ||= trackable_parents_and_trackable.last
end
def trackable_parents
@trackable_parents ||= trackable_parents_and_trackable[0, -1]
end
def trackable_parent
@trackable_parent ||= trackable_parents_and_trackable[-2]
end
# Outputs a :from, :to hash for each affected field. Intentionally excludes fields
# which are not tracked, even if there are tracked values for such fields
# present in the database.
#
# @return [ HashWithIndifferentAccess ] a change set in the format:
# { field_1: {to: new_val}, field_2: {from: old_val, to: new_val} }
def tracked_changes
@tracked_changes ||= (modified.keys | original.keys).inject(HashWithIndifferentAccess.new) do |h, k|
h[k] = { from: original[k], to: modified[k] }.delete_if { |_, vv| vv.nil? }
h
end.delete_if { |k, v| v.blank? || !trackable_parent_class.tracked?(k) }
end
# Outputs summary of edit actions performed: :add, :modify, :remove, or :array.
# Does deep comparison of arrays. Useful for creating human-readable representations
# of the history tracker. Considers changing a value to 'blank' to be a removal.
#
# @return [ HashWithIndifferentAccess ] a change set in the format:
# { add: { field_1: new_val, ... },
# modify: { field_2: {from: old_val, to: new_val}, ... },
# remove: { field_3: old_val },
# array: { field_4: {add: ['foo', 'bar'], remove: ['baz']} } }
def tracked_edits
return @tracked_edits if @tracked_edits
@tracked_edits = HashWithIndifferentAccess.new
tracked_changes.each do |k, v|
next if v[:from].blank? && v[:to].blank?
if trackable_parent_class.tracked_embeds_many?(k)
prepare_tracked_edits_for_embeds_many(k, v)
elsif v[:from].blank?
@tracked_edits[:add] ||= {}
@tracked_edits[:add][k] = v[:to]
elsif v[:to].blank?
@tracked_edits[:remove] ||= {}
@tracked_edits[:remove][k] = v[:from]
elsif v[:from].is_a?(Array) && v[:to].is_a?(Array)
@tracked_edits[:array] ||= {}
old_values = v[:from] - v[:to]
new_values = v[:to] - v[:from]
@tracked_edits[:array][k] = { add: new_values, remove: old_values }.delete_if { |_, vv| vv.blank? }
else
@tracked_edits[:modify] ||= {}
@tracked_edits[:modify][k] = v
end
end
@tracked_edits
end
# Similar to #tracked_changes, but contains only a single value for each
# affected field:
# - :create and :update return the modified values
# - :destroy returns original values
# Included for legacy compatibility.
#
# @deprecated
#
# @return [ HashWithIndifferentAccess ] a change set in the format:
# { field_1: value, field_2: value }
def affected
target = action.to_sym == :destroy ? :from : :to
@affected ||= tracked_changes.inject(HashWithIndifferentAccess.new) do |h, (k, v)|
h[k] = v[target]
h
end
end
# Returns the class of the trackable, irrespective of whether the trackable object
# has been destroyed.
#
# @return [ Class ] the class of the trackable
def trackable_parent_class
association_chain.first['name'].constantize
end
private
def re_create
association_chain.length > 1 ? create_on_parent : create_standalone
end
def re_destroy
trackable.destroy
end
def create_standalone
restored = trackable_parent_class.new(localize_keys(original))
restored.id = original['_id']
restored.save!
end
def create_on_parent
name = association_chain.last['name']
if trackable_parent.class.embeds_one?(name)
trackable_parent._create_relation(name, localize_keys(original))
elsif trackable_parent.class.embeds_many?(name)
trackable_parent._get_relation(name).create!(localize_keys(original))
else
raise 'This should never happen. Please report bug!'
end
end
def trackable_parents_and_trackable
@trackable_parents_and_trackable ||= traverse_association_chain
end
def traverse_association_chain
chain = association_chain.dup
doc = nil
documents = []
loop do
node = chain.shift
name = node['name']
doc = if doc.nil?
# root association. First element of the association chain
# unscoped is added to remove any default_scope defined in model
klass = name.classify.constantize
klass.unscoped.where(_id: node['id']).first
elsif doc.class.embeds_one?(name)
doc._get_relation(name)
elsif doc.class.embeds_many?(name)
doc._get_relation(name).unscoped.where(_id: node['id']).first
else
relation_klass = doc.class.relation_class_of(name) if doc
relation_klass ||= 'nil'
raise "Unexpected relation for field '#{name}': #{relation_klass}. This should never happen. Please report bug."
end
documents << doc
break if chain.empty?
end
documents
end
def localize_keys(hash)
klass = association_chain.first['name'].constantize
if klass.respond_to?(:localized_fields)
klass.localized_fields.keys.each do |name|
hash["#{name}_translations"] = hash.delete(name) if hash[name].present?
end
end
hash
end
def prepare_tracked_edits_for_embeds_many(key, value)
@tracked_edits[:embeds_many] ||= {}
value[:from] ||= []
value[:to] ||= []
modify_ids = value[:from].map { |vv| vv['_id'] }.compact & value[:to].map { |vv| vv['_id'] }.compact
modify_values = modify_ids.map { |id| { from: value[:from].detect { |vv| vv['_id'] == id }, to: value[:to].detect { |vv| vv['_id'] == id } } }
modify_values.delete_if { |vv| vv[:from] == vv[:to] }
ignore_values = modify_values.map { |vv| [vv[:from], vv[:to]] }.flatten
old_values = value[:from] - value[:to] - ignore_values
new_values = value[:to] - value[:from] - ignore_values
@tracked_edits[:embeds_many][key] = { add: new_values, remove: old_values, modify: modify_values }.delete_if { |_, vv| vv.blank? }
end
end
end
end