ancestry/lib/ancestry/instance_methods.rb

349 lines
9.6 KiB
Ruby

module Ancestry
module InstanceMethods
BEFORE_LAST_SAVE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_before_last_save' : '_was'
IN_DATABASE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_in_database' : '_was'
# Validate that the ancestors don't include itself
def ancestry_exclude_self
errors.add(:base, "#{self.class.name.humanize} cannot be a descendant of itself.") if ancestor_ids.include? self.id
end
# Update descendants with new ancestry (before save)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry?
# ... for each descendant ...
unscoped_descendants.each do |descendant|
# ... replace old ancestry with new ancestry
descendant.without_ancestry_callbacks do
new_ancestor_ids = path_ids + (descendant.ancestor_ids - path_ids_was)
descendant.update_attribute(:ancestor_ids, new_ancestor_ids)
end
end
end
end
# Apply orphan strategy (before destroy - no changes)
def apply_orphan_strategy
if !ancestry_callbacks_disabled? && !new_record?
case self.ancestry_base_class.orphan_strategy
when :rootify # make all children root if orphan strategy is rootify
unscoped_descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids - path_ids
end
end
when :destroy # destroy all descendants if orphan strategy is destroy
unscoped_descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.destroy
end
end
when :adopt # make child elements of this node, child of its parent
descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids.delete_if { |x| x == self.id }
end
end
when :restrict # throw an exception if it has children
raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless?
end
end
end
# Touch each of this record's ancestors (after save)
def touch_ancestors_callback
if !ancestry_callbacks_disabled? && self.ancestry_base_class.touch_ancestors
# Touch each of the old *and* new ancestors
unscoped_current_and_previous_ancestors.each do |ancestor|
ancestor.without_ancestry_callbacks do
ancestor.touch
end
end
end
end
# Counter Cache
def increase_parent_counter_cache
self.class.increment_counter _counter_cache_column, parent_id
end
def decrease_parent_counter_cache
# @_trigger_destroy_callback comes from activerecord, which makes sure only once decrement when concurrent deletion.
# but @_trigger_destroy_callback began after rails@5.1.0.alpha.
# https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L340
# https://github.com/rails/rails/pull/14735
# https://github.com/rails/rails/pull/27248
return if defined?(@_trigger_destroy_callback) && !@_trigger_destroy_callback
return if ancestry_callbacks_disabled?
self.class.decrement_counter _counter_cache_column, parent_id
end
def update_parent_counter_cache
changed =
if ActiveRecord::VERSION::STRING >= '5.1.0'
saved_change_to_attribute?(self.ancestry_base_class.ancestry_column)
else
ancestry_changed?
end
return unless changed
if parent_id_was = parent_id_before_last_save
self.class.decrement_counter _counter_cache_column, parent_id_was
end
parent_id && self.class.increment_counter(_counter_cache_column, parent_id)
end
def _counter_cache_column
self.ancestry_base_class.counter_cache_column.to_s
end
# Ancestors
def ancestors?
ancestor_ids.present?
end
alias :has_parent? :ancestors?
def ancestry_changed?
column = self.ancestry_base_class.ancestry_column.to_s
if ActiveRecord::VERSION::STRING >= '5.1.0'
# These methods return nil if there are no changes.
# This was fixed in a refactoring in rails 6.0: https://github.com/rails/rails/pull/35933
!!(will_save_change_to_attribute?(column) || saved_change_to_attribute?(column))
else
changed.include?(column)
end
end
def ancestor_conditions
self.ancestry_base_class.ancestor_conditions(self)
end
def ancestors depth_options = {}
return self.ancestry_base_class.none unless ancestors?
self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.where ancestor_conditions
end
def path_ids
ancestor_ids + [id]
end
def path_ids_was
ancestor_ids_was + [id]
end
def path_conditions
self.ancestry_base_class.path_conditions(self)
end
def path depth_options = {}
self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.where path_conditions
end
def depth
ancestor_ids.size
end
def cache_depth
write_attribute self.ancestry_base_class.depth_cache_column, depth
end
def ancestor_of?(node)
node.ancestor_ids.include?(self.id)
end
# Parent
# currently parent= does not work in after save callbacks
# assuming that parent hasn't changed
def parent= parent
self.ancestor_ids = parent ? parent.path_ids : []
end
def parent_id= new_parent_id
self.parent = new_parent_id.present? ? unscoped_find(new_parent_id) : nil
end
def parent_id
ancestor_ids.last if ancestors?
end
alias :parent_id? :ancestors?
def parent
unscoped_find(parent_id) if ancestors?
end
def parent_of?(node)
self.id == node.parent_id
end
# Root
def root_id
ancestors? ? ancestor_ids.first : id
end
def root
ancestors? ? unscoped_find(root_id) : self
end
def is_root?
!ancestors?
end
alias :root? :is_root?
def root_of?(node)
self.id == node.root_id
end
# Children
def child_conditions
self.ancestry_base_class.child_conditions(self)
end
def children
self.ancestry_base_class.where child_conditions
end
def child_ids
children.pluck(self.ancestry_base_class.primary_key)
end
def has_children?
self.children.exists?
end
alias_method :children?, :has_children?
def is_childless?
!has_children?
end
alias_method :childless?, :is_childless?
def child_of?(node)
self.parent_id == node.id
end
# Siblings
def sibling_conditions
self.ancestry_base_class.sibling_conditions(self)
end
def siblings
self.ancestry_base_class.where sibling_conditions
end
# NOTE: includes self
def sibling_ids
siblings.pluck(self.ancestry_base_class.primary_key)
end
def has_siblings?
self.siblings.count > 1
end
alias_method :siblings?, :has_siblings?
def is_only_child?
!has_siblings?
end
alias_method :only_child?, :is_only_child?
def sibling_of?(node)
self.ancestor_ids == node.ancestor_ids
end
# Descendants
def descendant_conditions
self.ancestry_base_class.descendant_conditions(self)
end
def descendants depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).where descendant_conditions
end
def descendant_ids depth_options = {}
descendants(depth_options).pluck(self.ancestry_base_class.primary_key)
end
def descendant_of?(node)
ancestor_ids.include?(node.id)
end
# Indirects
def indirect_conditions
self.ancestry_base_class.indirect_conditions(self)
end
def indirects depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).where indirect_conditions
end
def indirect_ids depth_options = {}
indirects(depth_options).pluck(self.ancestry_base_class.primary_key)
end
def indirect_of?(node)
ancestor_ids[0..-2].include?(node.id)
end
# Subtree
def subtree_conditions
self.ancestry_base_class.subtree_conditions(self)
end
def subtree depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).where subtree_conditions
end
def subtree_ids depth_options = {}
subtree(depth_options).pluck(self.ancestry_base_class.primary_key)
end
# Callback disabling
def without_ancestry_callbacks
@disable_ancestry_callbacks = true
yield
@disable_ancestry_callbacks = false
end
def ancestry_callbacks_disabled?
defined?(@disable_ancestry_callbacks) && @disable_ancestry_callbacks
end
private
def unscoped_descendants
unscoped_where do |scope|
scope.where descendant_conditions
end
end
# works with after save context (hence before_last_save)
def unscoped_current_and_previous_ancestors
unscoped_where do |scope|
scope.where id: (ancestor_ids + ancestor_ids_before_last_save).uniq
end
end
def unscoped_find id
unscoped_where do |scope|
scope.find id
end
end
def unscoped_where
self.ancestry_base_class.unscoped_where do |scope|
yield scope
end
end
end
end