lib/inventory_refresh/inventory_object.rb
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