ManageIQ/manageiq

View on GitHub
app/models/ems_refresh/save_inventory_helper.rb

Summary

Maintainability
A
0 mins
Test Coverage
F
23%
module EmsRefresh::SaveInventoryHelper
  class TypedIndex
    attr_accessor :record_index, :key_attribute_types
    attr_accessor :find_key

    def initialize(records, find_key)
      # Save the columns associated with the find keys, so we can coerce the hash values during fetch
      if records.first
        model = records.first.class
        @key_attribute_types = find_key.map { |k| model.type_for_attribute(k) }
      else
        @key_attribute_types = []
      end

      # Index the records by the values from the find_key
      @record_index = records.each_with_object({}) do |r, h|
        h.store_path(find_key.collect { |k| r.send(k) }, r)
      end

      @find_key = find_key
    end

    def fetch(hash)
      return nil if record_index.blank?

      hash_values = find_key.collect { |k| hash[k] }

      # Coerce each hash value into the db column type for valid lookup during fetch_path
      coerced_hash_values = hash_values.zip(key_attribute_types).collect do |value, type|
        type.cast(value)
      end

      record_index.fetch_path(coerced_hash_values)
    end
  end

  def save_inventory_multi(association, hashes, deletes, find_key, child_keys = [], extra_keys = [], disconnect = false)
    association.reset

    if deletes == :use_association
      deletes = association
    elsif deletes.respond_to?(:reload) && deletes.loaded?
      deletes.reload
    end
    deletes = deletes.to_a
    deletes_index = deletes.index_by { |x| x }
    # Alow GC to clean the AR objects as they are removed from deletes_index
    deletes = nil

    child_keys = Array.wrap(child_keys)
    remove_keys = Array.wrap(extra_keys) + child_keys

    record_index = TypedIndex.new(association, find_key)

    new_records = []

    ActiveRecord::Base.transaction do
      hashes.each do |h|
        found = save_inventory_with_findkey(association, h.except(*remove_keys), deletes_index, new_records, record_index)
        save_child_inventory(found, h, child_keys)
      end
    end

    # Delete the items no longer found
    deletes = deletes_index.values
    if deletes.present?
      ActiveRecord::Base.transaction do
        type = association.proxy_association.reflection.name
        _log.info("[#{type}] Deleting #{log_format_deletes(deletes)}")
        disconnect ? deletes.each(&:disconnect_inv) : association.delete(deletes)
      end
    end

    # Add the new items
    association.push(new_records)
  end

  def save_inventory_single(type, parent, hash, child_keys = [], extra_keys = [], disconnect = false)
    child = parent.send(type)
    if hash.blank?
      disconnect ? child.try(:disconnect_inv) : child.try(:destroy)
      return
    end

    child_keys = Array.wrap(child_keys)
    remove_keys = Array.wrap(extra_keys) + child_keys + [:id]
    if child
      update!(child, hash, [:type, *remove_keys])
    else
      child = parent.send(:"create_#{type}!", hash.except(*remove_keys))
    end
    save_child_inventory(child, hash, child_keys)
  end

  def save_inventory_with_findkey(association, hash, deletes, new_records, record_index)
    # Find the record, and update if found, else create it
    found = record_index.fetch(hash)
    if found.nil?
      found = association.build(hash.except(:id))
      new_records << found
    else
      update!(found, hash, [:id, :type])
      deletes.delete(found) if deletes.present?
    end
    found
  end

  def update!(ar_model, attributes, remove_keys)
    ar_model.assign_attributes(attributes.except(*remove_keys))
    # HACK: Avoid empty BEGIN/COMMIT pair until fix is made for https://github.com/rails/rails/issues/17937
    ar_model.save! if ar_model.changed?
  end

  def backup_keys(hash, keys)
    keys.each_with_object({}) { |k, backup| backup[k] = hash.delete(k) if hash.key?(k) }
  end

  def restore_keys(hash, keys, backup)
    keys.each { |k| hash[k] = backup.delete(k) if backup.key?(k) }
  end

  def save_child_inventory(obj, hashes, child_keys, *args)
    child_keys.each { |k| send(:"save_#{k}_inventory", obj, hashes[k], *args) if hashes.key?(k) }
  end

  def store_ids_for_new_records(records, hashes, keys)
    return if records.blank?

    keys = Array(keys)
    # Lets first index the hashes based on keys, so we can do O(1) lookups
    record_index = records.index_by { |record| build_index_from_record(keys, record) }
    record_class = records.first.class.base_class

    hashes.each do |hash|
      record = record_index[build_index_from_hash(keys, hash, record_class)]
      hash[:id] = record.id
    end
  end

  def build_index_from_hash(keys, hash, record_class)
    keys.map { |key| record_class.type_for_attribute(key.to_s).cast(hash[key]) }
  end

  def build_index_from_record(keys, record)
    keys.map { |key| record.send(key) }
  end

  def link_children_references(records)
    records.each do |rec|
      parent = records.detect { |r| r.manager_ref == rec.parent_ref } if rec.parent_ref.present?
      rec.update(:parent_id => parent.try(:id))
    end
  end

  # most of the refresh_inventory_multi calls follow the same pattern
  # this pulls it out
  def save_inventory_assoc(association, hashes, target, find_key = [], child_keys = [], extra_keys = [])
    deletes = relation_values(association, target)
    save_inventory_multi(association, hashes, deletes, find_key, child_keys, extra_keys)
    store_ids_for_new_records(association, hashes, find_key)
  end

  # We need to determine our intent:
  # - make a complete refresh. Delete missing records.
  # - make a partial refresh. Don't delete missing records.
  # This generates the "deletes" values based upon this intent
  # It will delete missing records if both of the following are true:
  # - The association is declared as a top_level association
  #   In Active Record, :dependent => :destroy says the parent controls the lifespan of the children
  # - We are targeting this association
  #   If we are targeting something else, chances are it is a partial refresh. Don't delete.
  #   If we are targeting this node, or targeting anything (nil), then delete.
  #   Some places don't have the target==parent concept. So they can pass in true instead.
  def relation_values(association, target)
    # always want to refresh this association
    # if this association isn't the definitive source
    top_level = association.proxy_association.options[:dependent] == :destroy

    top_level && (target == true || target.nil? || parent == target) ? :use_association : []
  end

  def determine_deletes_using_association(ems, target)
    if target == ems
      :use_association
    else
      []
    end
  end

  def hashes_of_target_empty?(hashes, target)
    hashes.blank? || (hashes[:storages].blank? &&
    case target
    when VmOrTemplate
      hashes[:vms].blank?
    when Host
      hashes[:hosts].blank?
    when EmsFolder
      hashes[:folders].blank?
    end)
  end
end