mceachen/closure_tree

View on GitHub
lib/closure_tree/hierarchy_maintenance.rb

Summary

Maintainability
A
1 hr
Test Coverage
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