ManageIQ/manageiq

View on GitHub
app/models/mixins/relationship_mixin.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
require 'memoist'
require 'ancestry'

module RelationshipMixin
  extend ActiveSupport::Concern

  MEMOIZED_METHODS = [
    :relationships_of,
    :parents,              :parent_rels,
    :root,                 :root_rel,
    :ancestors,            :ancestor_rels,
    :path,                 :path_rels,
    :siblings,             :sibling_rels,
    :has_siblings?,        :is_only_child?,
    :children,             :child_rels,
    :has_children?,        :is_childless?,
    :descendants,          :descendant_rels,
    :descendants_arranged, :descendant_rels_arranged,
    :subtree,              :subtree_rels,
    :subtree_arranged,     :subtree_rels_arranged,
    :fulltree,             :fulltree_rels,
    :fulltree_arranged,    :fulltree_rels_arranged,
    :parent_rel_ids,
  ]

  included do
    extend Memoist

    class_attribute :default_relationship_type
    class_attribute :skip_relationships, :default => []

    has_many :all_relationships, :class_name => "Relationship", :dependent => :destroy, :as => :resource

    memoize(*MEMOIZED_METHODS)
  end

  def reload(*args)
    clear_relationships_cache
    super
  end

  # only used from specs
  def clear_relationships_cache(*args)
    options = args.extract_options!
    to_clear = RelationshipMixin::MEMOIZED_METHODS - Array.wrap(options[:except])
    flush_cache(*to_clear) unless to_clear.empty?

    @association_cache.delete(:all_relationships)
  end

  def use_ancestry?
    skip_relationships.include?(relationship_type)
  end

  #
  # relationship_type scoping methods
  #

  def relationship_types
    @relationship_types ||= []
  end

  def relationship_type
    relationship_types.blank? ? default_relationship_type : relationship_types.last
  end

  def relationship_type=(rel)
    unless relationship_type == rel
      relationship_types.push(rel)
      clear_relationships_cache(:except => :relationships_of)
    end
    rel
  end

  def with_relationship_type(rel)
    raise _("no block given") unless block_given?

    rel = rel.to_s
    rel_changed = rel && (relationship_type != rel)
    self.relationship_type = rel unless rel.nil?

    begin
      yield(self)
    ensure
      if rel_changed
        relationship_types.pop
        clear_relationships_cache(:except => :relationships_of)
      end
    end
  end

  def relationships_of(rel_type)
    if @association_cache.include?(:all_relationships)
      all_relationships.select { |r| r.relationship == rel_type }
    else
      all_relationships.in_relationship(rel_type)
    end
  end

  def relationships
    relationships_of(relationship_type)
  end

  def relationship_ids
    relationships.collect(&:id)
  end

  #
  # has_ancestry methods
  #

  # Returns the id in the relationship table for this record's parents
  # from this id, relationship records can be brought back and mapped to the resource of interest
  # NOTE: parent_id is read from ancestry field, while parent is a db hit (N+1)
  # NOTE: relationships can be an array (from all_relationships cache) - so handle both Array and association
  def parent_rel_ids
    rel = relationships
    if rel.kind_of?(Array) || rel.try(:loaded?)
      rel.reject { |x| x.ancestry.blank? }.collect(&:parent_id)
    else
      rel.where.not(:ancestry => [nil, ""]).select(:ancestry).collect(&:parent_id)
    end
  end

  # Returns all of the relationships of the parents of the record, [] for a root node
  def parent_rels(*args)
    options = args.extract_options!
    pri = parent_rel_ids
    rels = pri.kind_of?(Array) && pri.empty? ? Relationship.none : Relationship.where(:id => pri)
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns all of the parents of the record, [] for a root node
  def parents(*args)
    Relationship.resources(parent_rels(*args))
  end

  # Returns all of the class/id pairs of the parents of the record, [] for a root node
  def parent_ids(*args)
    return Array.wrap(parent_id(*args)) if use_ancestry?

    Relationship.resource_pairs(parent_rels(*args))
  end

  # Returns the number of parents of the record
  def parent_count(*args)
    parent_rels(*args).size
  end

  # Returns the relationship of the parent of the record, nil for a root node
  def parent_rel(*args)
    rels = parent_rels(*args).take(2)
    raise _("Multiple parents found.") if rels.length > 1

    rels.first
  end

  # Returns the parent of the record, nil for a root node
  def parent(*args)
    return call_ancestry_method(:parent) if use_ancestry?

    rels = parent_rels(*args).take(2)
    raise _("Multiple parents found.") if rels.length > 1

    rels.first.try(:resource)
  end

  # Returns the class/id pair of the parent of the record, nil for a root node
  def parent_id(*args)
    return call_ancestry_method(:parent_id) if use_ancestry?

    rels = parent_ids(*args).take(2)
    raise _("Multiple parents found.") if rels.length > 1

    rels.first
  end

  # Returns the relationship of the root of the tree the record is in
  def root_rel
    rel = relationship&.root
    # micro-optimization: if the relationship is us, "load" the resource
    rel.resource = self if rel && rel.resource_id == id && rel.resource_type == self.class.base_class.name.to_s
    rel || relationship_for_isolated_root
  end

  # Returns the root of the tree the record is in, self for a root node
  def root(*args)
    return call_ancestry_method(:root) if use_ancestry?

    Relationship.resource(root_rel(*args))
  end

  # Returns the id of the root of the tree the record is in
  def root_id(*args)
    return call_ancestry_method(:root_id) if use_ancestry?

    Relationship.resource_pair(root_rel(*args))
  end

  # Returns true if the record is a root node, false otherwise
  def is_root?
    return call_ancestry_method(:is_root?) if use_ancestry?

    rel = relationship # TODO: Handle a node that is a root and a node at the same time
    rel.nil? ? true : rel.is_root?
  end

  # Returns a relationship for a record that is a root node with no corresponding
  #   relationship record, meaning it is an isolated root node
  def relationship_for_isolated_root
    Relationship.new(:resource => self)
  end
  private :relationship_for_isolated_root

  # Returns a list of ancestor relationships, starting with the root relationship
  #   and ending with the parent relationship
  def ancestor_rels(*args)
    options = args.extract_options!
    rel = relationship(:raise_on_multiple => true) # TODO: Handle multiple nodes with a way to detect which node you want
    rels = rel.nil? ? [] : rel.ancestors
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of ancestor records, starting with the root record and ending
  #   with the parent record
  def ancestors(*args)
    return call_ancestry_method(:ancestors) if use_ancestry?

    Relationship.resources(ancestor_rels(*args))
  end

  # Returns a list of ancestor class/id pairs, starting with the root class/id
  #   and ending with the parent class/id
  def ancestor_ids(*args)
    return call_ancestry_method(:ancestor_ids) if use_ancestry?

    Relationship.resource_pairs(ancestor_rels(*args))
  end

  # Returns the number of ancestor records
  def ancestors_count(*args)
    ancestor_rels(*args).size
  end

  # Returns a list of the path relationships, starting with the root relationship
  #   and ending with the node's own relationship
  def path_rels(*args)
    options = args.extract_options!
    rel = relationship(:raise_on_multiple => true) # TODO: Handle multiple nodes with a way to detect which node you want
    rels = rel.nil? ? [relationship_for_isolated_root] : rel.path
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of the path records, starting with the root record and ending
  #   with the node's own record
  def path(*args)
    return call_ancestry_method(:path) if use_ancestry?

    Relationship.resources(path_rels(*args)) # TODO: Prevent preload of self which is in the list
  end

  # Returns a list of the path class/id pairs, starting with the root class/id
  #   and ending with the node's own class/id
  def path_ids(*args)
    return call_ancestry_method(:path_ids) if use_ancestry?

    Relationship.resource_pairs(path_rels(*args))
  end

  # Returns the number of records in the path
  def path_count(*args)
    path_rels(*args).size
  end

  # Returns a list of child relationships
  def child_rels(*args)
    options = args.extract_options!
    rels = relationships.flat_map(&:children).uniq
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of child records
  def children(*args)
    return call_ancestry_method(:children) if use_ancestry?

    Relationship.resources(child_rels(*args))
  end

  # Returns a list of child class/id pairs
  def child_ids(*args)
    return call_ancestry_method(:child_ids) if use_ancestry?

    Relationship.resource_pairs(child_rels(*args))
  end

  # Returns the number of child records
  def child_count(*args)
    child_rels(*args).size
  end

  # Returns true if the record has any children, false otherwise
  def has_children?
    return call_ancestry_method(:has_children?) if use_ancestry?

    relationships.any?(&:has_children?)
  end

  # Returns true if the record has no children, false otherwise
  def is_childless?
    return call_ancestry_method(:is_childless?) if use_ancestry?

    relationships.all?(&:is_childless?)
  end

  # Returns a list of sibling relationships
  def sibling_rels(*args)
    options = args.extract_options!
    rels = relationships.flat_map(&:siblings).uniq
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of sibling records
  def siblings(*args)
    return call_ancestry_method(:siblings) if use_ancestry?

    Relationship.resources(sibling_rels(*args))
  end

  # Returns a list of sibling class/id pairs
  def sibling_ids(*args)
    return call_ancestry_method(:sibling_ids) if use_ancestry?

    Relationship.resource_pairs(sibling_rels(*args))
  end

  # Returns the number of sibling records
  def sibling_count(*args)
    sibling_rels(*args).size
  end

  # Returns true if the record's parent has more than one child
  def has_siblings?
    return call_ancestry_method(:has_siblings?) if use_ancestry?

    relationships.any?(&:has_siblings?)
  end

  # Returns true if the record is the only child of its parent
  def is_only_child?
    return call_ancestry_method(:is_only_child?) if use_ancestry?

    relationships.all?(&:is_only_child?)
  end

  # Returns a list of descendant relationships
  def descendant_rels(*args)
    options = args.extract_options!
    rels = relationships.flat_map(&:descendants).uniq
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of descendant records
  def descendants(*args)
    return call_ancestry_method(:descendants) if use_ancestry?

    Relationship.resources(descendant_rels(*args))
  end

  # Returns a list of descendant class/id pairs
  def descendant_ids(*args)
    return call_ancestry_method(:descendant_ids) if use_ancestry?

    Relationship.resource_pairs(descendant_rels(*args))
  end

  # Returns the number of descendant records
  def descendant_count(*args)
    descendant_rels(*args).size
  end

  # Returns the descendant relationships arranged in a tree
  def descendant_rels_arranged(*args)
    options = args.extract_options!
    rel = relationship(:raise_on_multiple => true)
    return {} if rel.nil?  # TODO: Should this return nil or init_relationship or Relationship.new in a Hash?

    Relationship.filter_by_resource_type(rel.descendants, options).arrange
  end

  # Returns the descendant class/id pairs arranged in a tree
  def descendant_ids_arranged(*args)
    Relationship.arranged_rels_to_resource_pairs(descendant_rels_arranged(*args))
  end

  # Returns the descendant records arranged in a tree
  def descendants_arranged(*args)
    Relationship.arranged_rels_to_resources(descendant_rels_arranged(*args))
  end

  # Returns a list of all relationships in the record's subtree
  def subtree_rels(*args)
    options = args.extract_options!
    # TODO: make this a single query (vs 3)
    # thus making filter_by_resource_type into a query
    rels = relationships.flat_map(&:subtree).uniq
    rels = [relationship_for_isolated_root] if rels.empty?
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of all records in the record's subtree
  def subtree(*args)
    return call_ancestry_method(:subtree) if use_ancestry?

    Relationship.resources(subtree_rels(*args)) # TODO: Prevent preload of self which is in the list
  end

  # Returns a list of all class/id pairs in the record's subtree
  def subtree_ids(*args)
    return call_ancestry_method(:subtree_ids) if use_ancestry?

    Relationship.resource_pairs(subtree_rels(*args))
  end

  # Returns the number of records in the record's subtree
  def subtree_count(*args)
    subtree_rels(*args).size
  end

  # Returns the subtree relationships arranged in a tree
  def subtree_rels_arranged(*args)
    options = args.extract_options!
    rel = relationship(:raise_on_multiple => true)
    return {relationship_for_isolated_root => {}} if rel.nil?

    Relationship.filter_by_resource_type(rel.subtree, options).arrange
  end

  # Returns the subtree class/id pairs arranged in a tree
  def subtree_ids_arranged(*args)
    Relationship.arranged_rels_to_resource_pairs(subtree_rels_arranged(*args))
  end

  # Returns the subtree records arranged in a tree
  def subtree_arranged(*args)
    Relationship.arranged_rels_to_resources(subtree_rels_arranged(*args))
  end

  def grandchild_rels(*args)
    options = args.extract_options!
    rels = relationships.inject(Relationship.none) do |stmt, r|
      stmt.or(r.grandchildren)
    end
    Relationship.filter_by_resource_type(rels, options)
  end

  def grandchildren(*args)
    Relationship.resources(grandchild_rels(*args))
  end

  def child_and_grandchild_rels(*args)
    options = args.extract_options!
    rels = relationships.inject(Relationship.none) do |stmt, r|
      stmt.or(r.child_and_grandchildren)
    end
    Relationship.filter_by_resource_type(rels, options)
  end

  # Return the depth of the node, root nodes are at depth 0
  def depth
    return call_ancestry_method(:depth) if use_ancestry?

    rel = relationship(:raise_on_multiple => true) # TODO: Handle multiple nodes with a way to detect which node you want
    rel.nil? ? 0 : rel.depth
  end

  #
  # Other methods
  #

  # Returns the relationship node for this record.  If there are multiple nodes,
  #   the first is returned, unless :raise_on_multiple is passed as true.
  def relationship(*args)
    options = args.extract_options!
    if options[:raise_on_multiple]
      rels = relationships.take(2)
      raise _("Multiple relationships found") if rels.length > 1

      rels.first
    else
      relationships.first
    end
  end

  # Adds a new relationship for this node
  def add_relationship(parent_rel = nil)
    clear_relationships_cache
    all_relationships.create!(
      :relationship => (parent_rel.nil? ? relationship_type : parent_rel.relationship),
      :parent       => parent_rel
    )
  end

  # Returns an existing relationship if found, otherwise creates a new one
  #   If parent_rel is passed, also connects the returned relationship to the
  #   parent, possibly delinking from an existing parent.
  def init_relationship(parent_rel = nil)
    rel = relationship
    if rel.nil?
      rel = add_relationship(parent_rel)
    elsif !parent_rel.nil?
      rel.update_attribute(:parent, parent_rel)
    end
    rel
  end

  # Returns a String form of the ancestor class/id pairs of the record
  #   Accepts the usual options, plus the options for Relationship.stringify_*,
  #   as well as :include_self which defaults to false.
  def relationship_ancestry(*args)
    stringify_options = args.extract_options!
    options = stringify_options.slice!(:field_delimiter, :record_delimiter, :exclude_class, :field_method, :include_self)

    include_self = stringify_options.delete(:include_self)
    field_method = stringify_options[:field_method] || :id

    meth = include_self ? :path : :ancestors
    meth = :"#{meth.to_s.singularize}_ids" if field_method == :id
    rels = send(meth, options)

    rels_meth = :"stringify_#{field_method == :id ? "resource_pairs" : "rels"}"
    Relationship.send(rels_meth, rels, stringify_options)
  end

  # Returns a list of all relationships in the tree from the root
  def fulltree_rels(*args)
    options = args.extract_options!
    root_id = relationship.try(:root_id)
    rels = root_id ? Relationship.subtree_of(root_id).uniq : [relationship_for_isolated_root]
    Relationship.filter_by_resource_type(rels, options)
  end

  # Returns a list of all records in the tree from the root
  def fulltree(*args)
    Relationship.resources(fulltree_rels(*args)) # TODO: Prevent preload of self which is in the list
  end

  # Returns a list of all class/id pairs in the tree from the root
  def fulltree_ids(*args)
    Relationship.resource_pairs(fulltree_rels(*args))
  end

  # Returns the number of records in the tree from the root
  def fulltree_count(*args)
    fulltree_rels(*args).size
  end

  # Returns the relationships in the tree from the root arranged in a tree
  def fulltree_rels_arranged(*args)
    options = args.extract_options!
    root_id = relationship.try(:root_id)
    return {relationship_for_isolated_root => {}} if root_id.nil?

    Relationship.filter_by_resource_type(Relationship.subtree_of(root_id), options).arrange
  end

  # Returns the class/id pairs in the tree from the root arranged in a tree
  def fulltree_ids_arranged(*args)
    Relationship.arranged_rels_to_resource_pairs(fulltree_rels_arranged(*args))
  end

  # Returns the records in the tree from the root arranged in a tree
  def fulltree_arranged(*args)
    Relationship.arranged_rels_to_resources(fulltree_rels_arranged(*args))
  end

  # Returns a list of all unique child types
  def child_types(*args)
    Relationship.resource_types(child_rels(*args))
  end

  def add_parent(parent)
    return update(:parent => parent) if use_ancestry?

    parent.with_relationship_type(relationship_type) { parent.add_child(self) }
  end

  def add_children(*child_objs)
    return child_objs.flatten.each { |child| child.with_relationship_type(relationship_type) { child.update!(:parent => self) } } if use_ancestry?

    options = child_objs.extract_options!
    child_objs = child_objs.flatten

    # Determine which child relationships already exist
    unless options[:skip_check] || (child_ids = self.child_ids).empty?
      child_objs = child_objs.reject { |c| child_ids.include?([c.class.base_class.name, c.id]) }
    end

    return child_objs if child_objs.empty?

    # Add the child relationships that do not already exist
    Relationship.transaction do
      parent_rel = init_relationship
      child_objs.each do |c|
        c.with_relationship_type(relationship_type) do
          # init_relationship will de-link from an existing parent, so if the
          #   child is already connected to a parent, we just want add a new
          #   child relationship, creating a duplicate node in the tree.  However,
          #   if the child is a root, we want to use init_relationship to link
          #   to the new parent directly to avoid creating multiple roots in the
          #   tree.
          c.send(c.is_root? ? :init_relationship : :add_relationship, parent_rel)
        end
      end
    end

    child_objs.each(&:clear_relationships_cache)
    clear_relationships_cache

    child_objs
  end
  alias_method :add_child, :add_children

  def parent=(parent)
    if parent.nil?
      if use_ancestry?
        call_ancestry_method(:parent=, parent)
      else
        remove_all_parents
      end
    else
      parent.with_relationship_type(relationship_type) do
        if use_ancestry?
          call_ancestry_method(:parent=, parent)
        else
          parent_rel = parent.init_relationship
          init_relationship(parent_rel) # TODO: Deal with any multi-instances

          parent.clear_relationships_cache
        end
      end
    end

    clear_relationships_cache
  end
  alias_method :replace_parent, :parent=

  #
  # Backward compatibility methods
  #

  alias_method :set_parent, :parent=
  alias_method :set_child,  :add_children

  def replace_children(*child_objs)
    child_objs = child_objs.flatten
    return remove_all_children if child_objs.empty?

    if use_ancestry?
      ids_to_add = child_objs.collect(&:id) - children.collect(&:id)
      to_add, to_keep = child_objs.partition { |c| ids_to_add.include?(c.id) }
      remove_children(children - to_keep)
      add_children(to_add)
      return
    end

    # Determine which child relationships should be destroyed, already exist, or should be added
    child_rels = self.child_rels

    child_rel_ids = child_rels.collect(&:resource_pair)
    child_obj_ids = child_objs.collect { |c| [c.class.base_class.name, c.id] }

    to_del = child_rel_ids - child_obj_ids
    to_del = child_rels.select { |r| to_del.include?(r.resource_pair) }

    to_add = child_obj_ids - child_rel_ids
    to_add, to_keep = child_objs.partition { |c| to_add.include?([c.class.base_class.name, c.id]) }

    Relationship.transaction do
      to_keep.each(&:clear_relationships_cache)
      remove_all_relationships(to_del)
      add_children(to_add, :skip_check => true)
    end
  end

  def remove_parent(parent)
    return update!(:parent => nil) if use_ancestry?

    parent.with_relationship_type(relationship_type) { parent.remove_child(self) }
  end

  def remove_children(*child_objs)
    child_objs = child_objs.flatten.compact
    return child_objs if child_objs.empty?

    return child_objs.each { |child| child.with_relationship_type(relationship_type) { child.update!(:parent => nil) } } if use_ancestry?

    child_rels = self.child_rels

    # Determine which child relationships should be destroyed or already exist
    child_obj_ids = child_objs.collect { |c| [c.class.base_class.name, c.id] }
    to_del, to_keep = child_rels.partition { |r| child_obj_ids.include?(r.resource_pair) }

    child_objs.each(&:clear_relationships_cache)
    if to_keep.empty?
      remove_all_children
    else
      remove_all_relationships(to_del)
    end
  end
  alias_method :remove_child, :remove_children

  def remove_all_parents(*args)
    return update!(:parent => nil) if use_ancestry?

    parents(*args).collect { |p| remove_parent(p) }
  end

  def remove_all_children(*args)
    return children.each { |child| remove_children(child) } if use_ancestry?

    # Determine if we are removing all or some children
    options = args.last.kind_of?(Hash) ? args.last : {}
    of_type = Array.wrap(options[:of_type])
    all_children_removed = of_type.empty? || (child_types - of_type).empty?

    if is_root? && all_children_removed
      remove_all_relationships
    else
      remove_all_relationships(child_rels(*args))
    end
  end

  def remove_all_relationships(*rels)
    rels = relationships if rels.empty?
    rels = rels.first if rels.length == 1 && rels.first.kind_of?(Array)

    unless rels.empty?
      Relationship.transaction do
        rels.each(&:destroy)
      end
      clear_relationships_cache
    end
    rels
  end

  def is_descendant_of?(obj)
    return call_ancestry_method(:descendant_of?, obj) if use_ancestry?

    ancestor_ids.include?([obj.class.base_class.name, obj.id])
  end

  def is_ancestor_of?(obj)
    return obj.with_relationship_type(relationship_type) { obj.descendant_of?(self) } if use_ancestry?

    descendant_ids.include?([obj.class.base_class.name, obj.id])
  end

  def detect_ancestor(*args, &block)
    ancestors(*args).reverse.detect(&block)
  end

  #
  # Diagnostic methods
  #

  def puts_relationship_tree
    Relationship.puts_arranged_resources(subtree_arranged)
  end

  private

  # this allows us to call similarly named ancestry or mixin methods
  def call_ancestry_method(method_name, *args)
    bound_method = if Ancestry::MaterializedPath::InstanceMethods.public_instance_methods.include?(method_name)
                     ((@ancestry_method ||= {})[method_name] ||= ::Ancestry::MaterializedPath::InstanceMethods.instance_method(method_name)).bind(self)
                   else
                     ((@ancestry_method ||= {})[method_name] ||= ::Ancestry::InstanceMethods.instance_method(method_name)).bind(self)
                   end

    args.empty? ? bound_method.call : bound_method.call(*args)
  end
end