ManageIQ/inventory_refresh

View on GitHub
lib/inventory_refresh/inventory_object.rb

Summary

Maintainability
D
1 day
Test Coverage
A
95%
require "inventory_refresh/application_record_reference"
require "active_support/core_ext/module/delegation"

module InventoryRefresh
  class InventoryObject
    attr_accessor :object, :id
    attr_reader :inventory_collection, :data, :reference

    delegate :manager_ref, :base_class_name, :model_class, :to => :inventory_collection
    delegate :[], :[]=, :to => :data

    # @param inventory_collection [InventoryRefresh::InventoryCollection] InventoryCollection object owning the
    #        InventoryObject
    # @param data [Hash] Data of the InventoryObject object
    def initialize(inventory_collection, data)
      @inventory_collection     = inventory_collection
      @data                     = data
      @object                   = nil
      @id                       = nil
      @reference                = inventory_collection.build_reference(data)
    end

    # @return [String] stringified reference
    def manager_uuid
      reference.stringified_reference
    end

    # @return [Hash] hash reference having :manager_ref keys, which can uniquely identity entity under a manager
    def uuid
      reference.full_reference.slice(*reference.keys).stringify_keys!
    end

    # @return [InventoryRefresh::InventoryObject] returns self
    def load(*_args)
      self
    end

    def key
      nil
    end

    # Transforms InventoryObject object data into hash format with keys that are column names and resolves correct
    # values of the foreign keys (even the polymorphic ones)
    #
    # @param inventory_collection_scope [InventoryRefresh::InventoryCollection] parent InventoryCollection object
    # @return [Hash] Data in DB format
    def attributes(inventory_collection_scope = nil)
      # We should explicitly pass a scope, since the inventory_object can be mapped to more InventoryCollections with
      # different blacklist and whitelist. The generic code always passes a scope.
      inventory_collection_scope ||= inventory_collection

      attributes_for_saving = {}
      # First transform the values
      data.each do |key, value|
        if !allowed?(inventory_collection_scope, key)
          next
        elsif value.kind_of?(Array) && value.any? { |x| loadable?(x) }
          # Lets fill also the original data, so other InventoryObject referring to this attribute gets the right
          # result
          data[key] = value.compact.map(&:load).compact
          # We can use built in _ids methods to assign array of ids into has_many relations. So e.g. the :key_pairs=
          # relation setter will become :key_pair_ids=
          attributes_for_saving["#{key.to_s.singularize}_ids".to_sym] = data[key].map(&:id).compact.uniq
        elsif loadable?(value) || inventory_collection_scope.association_to_foreign_key_mapping[key]
          # Lets fill also the original data, so other InventoryObject referring to this attribute gets the right
          # result
          data[key] = value.load if value.respond_to?(:load)
          if (foreign_key = inventory_collection_scope.association_to_foreign_key_mapping[key])
            # We have an association to fill, lets fill also the :key, cause some other InventoryObject can refer to it
            record_id                                 = data[key].try(:id)
            attributes_for_saving[foreign_key.to_sym] = record_id

            if (foreign_type = inventory_collection_scope.association_to_foreign_type_mapping[key])
              # If we have a polymorphic association, we need to also fill a base class name, but we want to nullify it
              # if record_id is missing
              base_class = data[key].try(:base_class_name) || data[key].class.try(:base_class).try(:name)
              attributes_for_saving[foreign_type.to_sym] = record_id ? base_class : nil
            end
          elsif data[key].kind_of?(::InventoryRefresh::InventoryObject)
            # We have an association to fill but not an Activerecord association, so e.g. Ancestry, lets just load
            # it here. This way of storing ancestry is ineffective in DB call count, but RAM friendly
            attributes_for_saving[key.to_sym] = data[key].base_class_name.constantize.find_by(:id => data[key].id)
          else
            # We have a normal attribute to fill
            attributes_for_saving[key.to_sym] = data[key]
          end
        else
          attributes_for_saving[key.to_sym] = value
        end
      end

      attributes_for_saving
    end

    # Transforms InventoryObject object data into hash format with keys that are column names and resolves correct
    # values of the foreign keys (even the polymorphic ones)
    #
    # @param inventory_collection_scope [InventoryRefresh::InventoryCollection] parent InventoryCollection object
    # @param all_attribute_keys [Array<Symbol>] Attribute keys we will modify based on object's data
    # @param inventory_object [InventoryRefresh::InventoryObject] InventoryObject object owning these attributes
    # @return [Hash] Data in DB format
    def attributes_with_keys(inventory_collection_scope = nil, all_attribute_keys = [], inventory_object = nil)
      # We should explicitly pass a scope, since the inventory_object can be mapped to more InventoryCollections with
      # different blacklist and whitelist. The generic code always passes a scope.
      inventory_collection_scope ||= inventory_collection

      attributes_for_saving = {}
      # First transform the values
      data.each do |key, value|
        if !allowed?(inventory_collection_scope, key)
          next
        elsif loadable?(value) || inventory_collection_scope.association_to_foreign_key_mapping[key]
          # Lets fill also the original data, so other InventoryObject referring to this attribute gets the right
          # result
          data[key] = value.load(inventory_object, key) if value.respond_to?(:load)
          if (foreign_key = inventory_collection_scope.association_to_foreign_key_mapping[key])
            # We have an association to fill, lets fill also the :key, cause some other InventoryObject can refer to it
            record_id = data[key].try(:id)
            foreign_key_to_sym = foreign_key.to_sym
            attributes_for_saving[foreign_key_to_sym] = record_id
            all_attribute_keys << foreign_key_to_sym
            if (foreign_type = inventory_collection_scope.association_to_foreign_type_mapping[key])
              # If we have a polymorphic association, we need to also fill a base class name, but we want to nullify it
              # if record_id is missing
              base_class = data[key].try(:base_class_name) || data[key].class.try(:base_class).try(:name)
              foreign_type_to_sym = foreign_type.to_sym
              attributes_for_saving[foreign_type_to_sym] = record_id ? base_class : nil
              all_attribute_keys << foreign_type_to_sym
            end
          else
            # We have a normal attribute to fill
            attributes_for_saving[key] = data[key]
            all_attribute_keys << key
          end
        else
          attributes_for_saving[key] = value
          all_attribute_keys << key
        end
      end

      attributes_for_saving
    end

    # Given hash of attributes, we assign them to InventoryObject object using its public writers
    #
    # @param attributes [Hash] attributes we want to assign
    # @return [InventoryRefresh::InventoryObject] self
    def assign_attributes(attributes)
      attributes.each do |k, v|
        # We don't want timestamps or resource versions to be overwritten here, since those are driving the conditions
        next if %i[resource_timestamps resource_timestamps_max resource_timestamp].include?(k)
        next if %i[resource_counters resource_counters_max resource_counter].include?(k)

        if data[:resource_timestamp] && attributes[:resource_timestamp]
          assign_only_newest(:resource_timestamp, :resource_timestamps, attributes, data, k, v)
        elsif data[:resource_counter] && attributes[:resource_counter]
          assign_only_newest(:resource_counter, :resource_counters, attributes, data, k, v)
        else
          public_send("#{k}=", v)
        end
      end

      if attributes[:resource_timestamp]
        assign_full_row_version_attr(:resource_timestamp, attributes, data)
      elsif attributes[:resource_counter]
        assign_full_row_version_attr(:resource_counter, attributes, data)
      end

      self
    end

    # @return [String] stringified UUID
    def to_s
      manager_uuid
    end

    # @return [String] string format for nice logging
    def inspect
      "InventoryObject:('#{manager_uuid}', #{inventory_collection})"
    end

    # @return [TrueClass] InventoryObject object is always a dependency
    def dependency?
      true
    end

    # Adds setters and getters based on :inventory_object_attributes kwarg passed into InventoryCollection
    # Methods already defined should not be redefined (causes unexpected behaviour)
    #
    # @param inventory_object_attributes [Array<Symbol>]
    def self.add_attributes(inventory_object_attributes)
      defined_methods = InventoryRefresh::InventoryObject.instance_methods(false)

      inventory_object_attributes.each do |attr|
        unless defined_methods.include?("#{attr}=".to_sym)
          define_method("#{attr}=") do |value|
            data[attr] = value
          end
        end

        next if defined_methods.include?(attr.to_sym)

        define_method(attr) do
          data[attr]
        end
      end
    end

    private

    # Assigns value based on the version attributes. If versions are specified, it asigns attribute only if it's
    # newer than existing attribute.
    #
    # @param full_row_version_attr [Symbol] Attr name for full rows, allowed values are
    #        [:resource_timestamp, :resource_counter]
    # @param partial_row_version_attr [Symbol] Attr name for partial rows, allowed values are
    #        [:resource_timestamps, :resource_counters]
    # @param attributes [Hash] New attributes we are assigning
    # @param data [Hash] Existing attributes of the InventoryObject
    # @param k [Symbol] Name of the attribute we are assigning
    # @param v [Object] Value of the attribute we are assigning
    def assign_only_newest(full_row_version_attr, partial_row_version_attr, attributes, data, k, v)
      # If timestamps are in play, we will set only attributes that are newer
      specific_attr_timestamp = attributes[partial_row_version_attr].try(:[], k)
      specific_data_timestamp = data[partial_row_version_attr].try(:[], k)

      assign = if !specific_attr_timestamp
                 # Data have no timestamp, we will ignore the check
                 true
               elsif specific_attr_timestamp && !specific_data_timestamp
                 # Data specific timestamp is nil and we have new specific timestamp
                 if data.key?(k)
                   if attributes[full_row_version_attr] >= data[full_row_version_attr]
                     # We can save if the full timestamp is bigger, if the data already contains the attribute
                     true
                   end
                 else
                   # Data do not contain the attribute, so we are saving the newest
                   true
                 end
                 true
               elsif specific_attr_timestamp > specific_data_timestamp
                 # both partial timestamps are there, newer must be bigger
                 true
               end

      if assign
        public_send("#{k}=", v) # Attribute is newer than current one, lets use it
        (data[partial_row_version_attr] ||= {})[k] = specific_attr_timestamp if specific_attr_timestamp # and set the latest timestamp
      end
    end

    # Assigns attribute representing version of the whole row
    #
    # @param full_row_version_attr [Symbol] Attr name for full rows, allowed values are
    #        [:resource_timestamp, :resource_counter]
    # @param attributes [Hash] New attributes we are assigning
    # @param data [Hash] Existing attributes of the InventoryObject
    def assign_full_row_version_attr(full_row_version_attr, attributes, data)
      if attributes[full_row_version_attr] && data[full_row_version_attr]
        # If both timestamps are present, store the bigger one
        data[full_row_version_attr] = attributes[full_row_version_attr] if attributes[full_row_version_attr] > data[full_row_version_attr]
      elsif attributes[full_row_version_attr] && !data[full_row_version_attr]
        # We are assigning timestamp that was missing
        data[full_row_version_attr] = attributes[full_row_version_attr]
      end
    end

    # Return true passed key representing a getter is an association
    #
    # @param inventory_collection_scope [InventoryRefresh::InventoryCollection]
    # @param key [Symbol] key representing getter
    # @return [Boolean] true if the passed key points to association
    def association?(inventory_collection_scope, key)
      # Is the key an association on inventory_collection_scope model class?
      !inventory_collection_scope.association_to_foreign_key_mapping[key].nil?
    end

    # Return true if the attribute is allowed to be saved into the DB
    #
    # @param inventory_collection_scope [InventoryRefresh::InventoryCollection] InventoryCollection object owning the
    #        attribute
    # @param key [Symbol] attribute name
    # @return true if the attribute is allowed to be saved into the DB
    def allowed?(inventory_collection_scope, key)
      foreign_to_association = inventory_collection_scope.foreign_key_to_association_mapping[key] ||
                               inventory_collection_scope.foreign_type_to_association_mapping[key]

      return false if inventory_collection_scope.attributes_blacklist.present? &&
                      (inventory_collection_scope.attributes_blacklist.include?(key) ||
                        (foreign_to_association && inventory_collection_scope.attributes_blacklist.include?(foreign_to_association)))

      return false if inventory_collection_scope.attributes_whitelist.present? &&
                      (!inventory_collection_scope.attributes_whitelist.include?(key) &&
                        (!foreign_to_association || (foreign_to_association && inventory_collection_scope.attributes_whitelist.include?(foreign_to_association))))

      true
    end

    # Return true if the object is loadable, which we determine by a list of loadable classes.
    #
    # @param value [Object] object we test
    # @return true if the object is loadable
    def loadable?(value)
      value.kind_of?(::InventoryRefresh::InventoryObjectLazy) || value.kind_of?(::InventoryRefresh::InventoryObject) ||
        value.kind_of?(::InventoryRefresh::ApplicationRecordReference)
    end
  end
end