lib/closure_tree/hierarchy_maintenance.rb
require 'active_support/concern'
module ClosureTree
module HierarchyMaintenance
extend ActiveSupport::Concern
included do
validate :_ct_validate
before_save :_ct_before_save
after_save :_ct_after_save
before_destroy :_ct_before_destroy
end
def _ct_skip_cycle_detection!
@_ct_skip_cycle_detection = true
end
def _ct_skip_sort_order_maintenance!
@_ct_skip_sort_order_maintenance = true
end
def _ct_validate
if !(defined? @_ct_skip_cycle_detection) &&
!new_record? && # don't validate for cycles if we're a new record
changes[_ct.parent_column_name] && # don't validate for cycles if we didn't change our parent
parent.present? && # don't validate if we're root
parent.self_and_ancestors.include?(self) # < this is expensive :\
errors.add(_ct.parent_column_sym, I18n.t('closure_tree.loop_error', default: 'You cannot add an ancestor as a descendant'))
end
end
def _ct_before_save
@was_new_record = new_record?
true # don't cancel the save
end
def _ct_after_save
if public_send(:saved_changes)[_ct.parent_column_name] || @was_new_record
rebuild!
end
if public_send(:saved_changes)[_ct.parent_column_name] && !@was_new_record
# Resetting the ancestral collections addresses
# https://github.com/mceachen/closure_tree/issues/68
ancestor_hierarchies.reload
self_and_ancestors.reload
end
@was_new_record = false # we aren't new anymore.
@_ct_skip_sort_order_maintenance = false # only skip once.
true # don't cancel anything.
end
def _ct_before_destroy
_ct.with_advisory_lock do
delete_hierarchy_references
if _ct.options[:dependent] == :nullify
self.class.find(self.id).children.find_each { |c| c.rebuild! }
end
end
true # don't prevent destruction
end
def rebuild!(called_by_rebuild = false)
_ct.with_advisory_lock do
delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record
hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0)
unless root?
_ct.connection.execute <<-SQL.squish
INSERT INTO #{_ct.quoted_hierarchy_table_name}
(ancestor_id, descendant_id, generations)
SELECT x.ancestor_id, #{_ct.quote(_ct_id)}, x.generations + 1
FROM #{_ct.quoted_hierarchy_table_name} x
WHERE x.descendant_id = #{_ct.quote(_ct_parent_id)}
SQL
end
if _ct.order_is_numeric? && !@_ct_skip_sort_order_maintenance
_ct_reorder_prior_siblings_if_parent_changed
# Prevent double-reordering of siblings:
_ct_reorder_siblings if !called_by_rebuild
end
children.find_each { |c| c.rebuild!(true) }
_ct_reorder_children if _ct.order_is_numeric? && children.present?
end
end
def delete_hierarchy_references
_ct.with_advisory_lock do
# The crazy double-wrapped sub-subselect works around MySQL's limitation of subselects on the same table that is being mutated.
# It shouldn't affect performance of postgresql.
# See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
# Also: PostgreSQL doesn't support INNER JOIN on DELETE, so we can't use that.
_ct.connection.execute <<-SQL.squish
DELETE FROM #{_ct.quoted_hierarchy_table_name}
WHERE descendant_id IN (
SELECT DISTINCT descendant_id
FROM (SELECT descendant_id
FROM #{_ct.quoted_hierarchy_table_name}
WHERE ancestor_id = #{_ct.quote(id)}
OR descendant_id = #{_ct.quote(id)}
) #{ _ct.t_alias_keyword } x )
SQL
end
end
class_methods do
# Rebuilds the hierarchy table based on the parent_id column in the database.
# Note that the hierarchy table will be truncated.
def rebuild!
_ct.with_advisory_lock do
cleanup!
roots.find_each { |n| n.send(:rebuild!) } # roots just uses the parent_id column, so this is safe.
end
nil
end
def cleanup!
hierarchy_table = hierarchy_class.arel_table
[:descendant_id, :ancestor_id].each do |foreign_key|
alias_name = foreign_key.to_s.split('_').first + "s"
alias_table = Arel::Table.new(table_name).alias(alias_name)
arel_join = hierarchy_table.join(alias_table, Arel::Nodes::OuterJoin)
.on(alias_table[primary_key].eq(hierarchy_table[foreign_key]))
.join_sources
lonely_childs = hierarchy_class.joins(arel_join).where(alias_table[primary_key].eq(nil))
ids = lonely_childs.pluck(foreign_key)
hierarchy_class.where(hierarchy_table[foreign_key].in(ids)).delete_all
end
end
end
end
end