ManageIQ/manageiq-automation_engine

View on GitHub
lib/miq_automation_engine/engine/miq_ae_engine/miq_ae_object.rb

Summary

Maintainability
C
1 day
Test Coverage
A
90%
require 'more_core_extensions/core_ext/array/math'
require_relative 'miq_ae_state_machine'
module MiqAeEngine
  class MiqAeObject
    include MiqAeStateMachine
    PATH_SEPARATOR    = '/'.freeze
    MESSAGE_SEPARATOR = ','.freeze
    ENUM_SEPARATOR    = ','.freeze
    CLASS_SEPARATOR   = '::'.freeze
    COLLECT_SEPARATOR = ';'.freeze
    METHOD_SEPARATOR  = '.'.freeze
    DEFAULT_INSTANCE  = '.default'.freeze
    MISSING_INSTANCE  = '.missing'.freeze
    OPAQUE_PASSWORD   = '********'.freeze
    FIELD_ATTRIBUTES  = %w[collect on_entry on_exit on_error max_retries max_time].freeze
    FIELD_VALUES      = %w[value default_value].freeze
    FIELD_ALLKEYS     = FIELD_VALUES + FIELD_ATTRIBUTES

    BASE_NAMESPACE    = '$'.freeze
    BASE_CLASS        = 'object'.freeze
    BASE_OBJECT       = [BASE_NAMESPACE, BASE_CLASS].join(PATH_SEPARATOR)
    RE_METHOD_CALL    = /^[\s]*([\.\/\w]+)[\s]*(?:\((.*)\))?[\s]*$/.freeze
    RE_URI_ESCAPE     = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
    RE_SUBST          = /\$\{([^}]+)\}/.freeze
    RE_COLLECT_ARRAY  = /^[\s]*(?:([\.\/\w]+)[\s]*=[\s]*)?\[(.*)\](?:\.([^.]+))?/.freeze
    RE_COLLECT_HASH   = /^[\s]*(?:([\.\/\w]+)[\s]*=[\s]*)?\{(.*)\}(?:\.([^.]*))*/.freeze
    RE_COLLECT_STRING = /^[\s]*(?:([\.\/\w]+)[\s]*=[\s]*)?([\w]+)(?:\.([^.]*))*/.freeze
    # key => value {, key => value }*
    #                     Key    =>   number | "string" | 'string' | variable
    RE_HASH           = /(\w+)\s*=>\s*(\d+|\"[^\"]+\"|\'[^\']+\'|\w+)/.freeze
    # Default conversion for Service Models
    SM_LOOKUP         = Hash.new { |_, k| k.classify }.merge(
      'ems'               => 'ExtManagementSystem',
      'policy'            => 'MiqPolicy',
      'provision'         => 'MiqProvision',
      'provision_request' => 'MiqProvisionRequest',
      'request'           => 'MiqRequest',
      'server'            => 'MiqServer'
    )
    NULL_COALESCING_OPERATOR = '||'.freeze
    attr_accessor :attributes, :namespace, :klass, :instance, :object_name, :instance_methods, :workspace, :current_field, :current_message
    attr_accessor :node_parent
    attr_reader :node_children

    def initialize(workspace, namespace, klass, instance, object_name = nil)
      Benchmark.current_realtime[:object_count] += 1

      @workspace        = workspace
      @namespace        = namespace
      @klass            = klass
      @instance         = instance
      @attributes       = {}
      @fields           = {}
      @fields_ordered   = []
      @rels             = {}
      @instance_methods = {}
      @object_name      = object_name || MiqAeObject.fqname(@namespace, @klass, @instance)
      @class_fqname     = MiqAeClass.fqname(@namespace, @klass)
      @aec              = fetch_class
      @current_field    = nil
      @current_message  = nil
      @node_parent      = nil
      @node_children    = []

      # Collect Class and Instance Methods into Hashes
      unless @aec.nil?
        @aec.instance_methods.each { |m| @instance_methods[m.name.downcase] = m }

        @cm = @workspace.class_methods[@class_fqname.downcase] || {}
        @aec.class_methods.each { |m| @cm[m.name.downcase] = m unless @cm.key?(m.name.downcase) }
        @workspace.class_methods[@class_fqname.downcase] = @cm
      end

      @aei = nil
      if @instance.nil?
        @instance = DEFAULT_INSTANCE
      elsif !@aec.nil?
        Benchmark.realtime_block(:instance_fetch_time) do
          @aei = fetch_instance(@instance)
          if @aei.nil?
            $miq_ae_logger.info("Instance [#{@object_name}] not found in MiqAeDatastore - trying [#{MISSING_INSTANCE}]", :resource_id => @workspace.find_miq_request_id)
            # Try the .missing instance, if the requested one was not found
            @attributes['_missing_instance'] = @instance
            @instance = MISSING_INSTANCE
            @aei      = fetch_instance(@instance)
          end
        end

        raise MiqAeException::InstanceNotFound, "Instance [#{@object_name}] not found in MiqAeDatastore" if @aei.nil?
      end

      unless @aec.nil?
        Benchmark.realtime_block(:fields_time) do
          fields, = Benchmark.realtime_block(:field_fetch_time) { @aec.ae_fields }
          fields.each do |f|
            Benchmark.current_realtime[:fetch_field_count] += 1
            attrs, = Benchmark.realtime_block(:field_attributes_time) { f.attributes }
            unless @aei.nil?
              attrs['value'] = @aei.get_field_value(f, false)
              FIELD_ATTRIBUTES.each do |key|
                attrib     = @aei.get_field_attribute(f, false, key.to_sym)
                attrs[key] = attrib unless attrib.blank?
              end
            end
            @fields[f.name] = attrs
            @fields_ordered << f.name
          end
        end
      end

      # Nothing more to do if Base Class
      return if @namespace.downcase == BASE_NAMESPACE && @klass.downcase == BASE_CLASS

      # Make sure we found what we needed before proceeding
      raise MiqAeException::ClassNotFound, "Class [#{@class_fqname}] not found in MiqAeDatastore" if @aec.nil?

      @workspace.add_obj_entry(@namespace, @klass, @instance, self)
      Benchmark.realtime_block(:inherit_time) do
        # Who do we inherit from
        @inherits = BASE_OBJECT

        parts     = @inherits.split(PATH_SEPARATOR)
        klass     = parts.pop
        ns        = parts.empty? ? @namespace : parts.join(PATH_SEPARATOR)
        instance  = @aei.nil? ? nil : @aei.inherits
        inherited = MiqAeObject.new(workspace, ns, klass, instance)
        ordered   = []
        inherited.fields.each do |f|
          k = f['name']
          if @fields.key?(k)
            FIELD_ALLKEYS.each { |hk| @fields[k][hk] ||= f[hk] }
          else
            @fields[k] = f
            ordered << k
          end
        end
        @fields_ordered = ordered + @fields_ordered

        # Set the inherited instance methods
        inherited.instance_methods.each { |k, v| @instance_methods[k.downcase] = v unless @instance_methods.key?(k.downcase) }

        # Set the inherited class methods
        inherited_class_fqname = MiqAeClass.fqname(ns, klass)
        unless @workspace.class_methods[inherited_class_fqname.downcase].nil?
          @workspace.class_methods[inherited_class_fqname.downcase].each { |k, v| @cm[k.downcase] = v unless @cm.key?(k.downcase) }
          @workspace.class_methods[@class_fqname.downcase] = @cm
        end
      end
    end

    def fetch_namespace(namespace = @namespace)
      Benchmark.current_realtime[:fetch_namespace_count] += 1
      Benchmark.realtime_block(:fetch_namespace_time) do
        @workspace.datastore(namespace.downcase, :namespace) do
          MiqAeNamespace.lookup_by_fqname(namespace)
        end
      end.first
    end

    def fetch_class(fqname = @class_fqname)
      Benchmark.current_realtime[:fetch_class_count] += 1
      Benchmark.realtime_block(:fetch_class_time) do
        @workspace.datastore(fqname.downcase.to_sym, :class) do
          MiqAeClass.lookup_by_fqname(fqname)
        end
      end.first
    end

    def fetch_instance(iname)
      Benchmark.current_realtime[:fetch_instance_count] += 1
      Benchmark.realtime_block(:fetch_instance_time) do
        @workspace.datastore(@class_fqname.downcase.to_sym, iname.downcase) do
          MiqAeInstance.where(:class_id => @aec.id).find_by(MiqAeInstance.arel_table[:name].lower.eq(iname.downcase))
        end
      end.first
    end

    def fetch_field_value(field)
      Benchmark.current_realtime[:fetch_field_value_count] += 1
      Benchmark.realtime_block(:fetch_field_value_time) do
        @aei&.get_field_value(field, false)
      end.first
    end

    def state_machine?
      @aec.state_machine?
    end

    def attribute_value_to_xml(value, xml)
      case value.class.to_s
      when 'MiqAePassword'            then xml.Password(OPAQUE_PASSWORD)
      when 'String'                   then xml.String(value)
      when 'Fixnum'                   then xml.Fixnum(value)
      when 'Symbol'                   then xml.Symbol(value.to_s)
      when 'TrueClass', 'FalseClass'  then xml.Boolean(value.to_s)
      when /MiqAeMethodService::(.*)/ then xml.tag!($1.gsub(/::/, '-'), :object_id => value.object_id, :id => value.id)
      when 'Array'
        xml.Array do
          value.each_index do |i|
            xml.Element(:index => i + 1) { attribute_value_to_xml(value[i], xml) }
          end
        end
      when 'Hash'
        xml.Hash do
          value.each do |k, v|
            xml.Key(:name => k.to_s) { attribute_value_to_xml(v, xml) }
          end
        end
      when 'DRb::DRbUnknown'
        $miq_ae_logger.error("Found DRbUnknown for value: #{value.inspect} in XML: #{xml.inspect}")
        xml.String(value)
      else
        xml.tag!(value.class.to_s.gsub(/::/, '-')) { xml.cdata!(value.inspect) }
      end
    end

    def to_xml(options = {})
      require 'builder'
      xml = options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent])
      xml_attrs = {:namespace => @namespace, :class => @klass, :instance => @instance}
      xml.MiqAeObject(xml_attrs) do
        @attributes.keys.sort.each do |k|
          xml.MiqAeAttribute(:name => k) { attribute_value_to_xml(@attributes[k], xml) }
        end

        children.each { |c| c.to_xml(:builder => xml) }
      end
    end

    def fields(message = nil)
      @fields_ordered.collect do |fname|
        @fields[fname] if message.nil? || self.class.message_matches?(message_parse(@fields[fname]['message']), message)
      end.compact
    end

    def [](attr)
      attributes[attr.downcase]
    end

    def []=(attr, value)
      attributes[attr.downcase] = value
    end

    def process_assertions(message)
      process_filtered_fields(['assertion'], message)
    end

    def user_info_attributes(user)
      {'user' => user, 'tenant' => user.current_tenant, 'miq_group' => user.current_group}.each do |k, v|
        value = MiqAeMethodService::MiqAeServiceModelBase.wrap_results(v)
        @attributes[k] = value unless value.nil?
      end
    end

    def process_args_as_attributes(args = {})
      args.keys.each { |k| MiqAeEngine.automation_attribute_is_array?(k) ? process_args_array(args, k) : process_args_attribute(args, k) }
      @attributes.merge!(args)
    end

    def process_args_array(args, args_key)
      # process Array::servers => MiqServer::2,MiqServer::3,MiqServer::4
      key = args_key.split(CLASS_SEPARATOR).last
      value = args.delete(args_key)
      args[key.downcase] = load_array_objects_from_string(value)
    end

    def process_args_attribute(args, args_key)
      # process MiqServer::svr => 2
      if args_key.include?(CLASS_SEPARATOR)
        key, klass = get_key_name_and_klass_from_key(args_key)
        value = args.delete(args_key)
        args["#{key}_id"] = value if attribute_for_vmdb_object?(klass, value) && !@attributes.key?(key)
        args[key] = MiqAeObject.convert_value_based_on_datatype(value, klass)
      elsif args_key != args_key.downcase
        args[args_key.downcase] = args.delete(args_key)
      end
    end

    def load_array_objects_from_string(objects_str)
      objects_str.split("\x1F").collect do |element|
        if element.include?(CLASS_SEPARATOR)
          klass, str_value = element.split(CLASS_SEPARATOR)
          MiqAeObject.convert_value_based_on_datatype(str_value.strip, klass.strip)
        else
          element.presence
        end
      end.compact
    end

    def get_key_name_and_klass_from_key(attribute_key)
      # If user passed in a datatype as part of the attribute name (e.g. MiqServer::svr => 2), process it
      sp    = attribute_key.split(CLASS_SEPARATOR)
      klass = sp[0..-2].join(CLASS_SEPARATOR)
      key   = sp.last.downcase
      return key, klass
    end

    def process_attributes(message, args = {})
      process_filtered_fields(['attribute'], message, args)
    end

    def process_fields(message, args = {})
      process_filtered_fields(['attribute', 'method', 'relationship', 'state'], message, args)
    end

    def process_filtered_fields(aetypes, message, args = {})
      fields(message).each do |f|
        next unless aetypes.include?(f['aetype'])

        begin
          @current_field   = f
          @current_message = message
          send("process_#{f['aetype']}", f, message, args)
        ensure
          @current_message = nil
          @current_field   = nil
        end
      end
    end

    def current_field_name
      current_field_element('name')
    end

    def current_field_type
      current_field_element('aetype')
    end

    def current_field_element(elem)
      @current_field ? @current_field[elem.to_s] : nil
    end

    def self.message_matches?(possible, message)
      possible.any? { |p| p == message || p == '*' }
    end

    def message_parse(message)
      return ['create'] if message.blank?

      message.split(MESSAGE_SEPARATOR).collect { |m| m.strip.downcase }
    end

    def children(name = nil)
      return node_children if name.nil?

      @rels[name]
    end

    def self.fqname(namespace, klass, instance)
      MiqAePath.new(:ae_namespace => namespace, :ae_class => klass, :ae_instance => instance).to_s
    end

    def process_relationship(field, message, args)
      process_relationship_raw(get_value(field, :aetype_relationship), message, args, field['name'], field['collect'])
    end

    def process_method_raw(method, collect = nil)
      return if method.blank? || method.lstrip[0, 1] == '#'

      Benchmark.current_realtime[:method_count] += 1
      Benchmark.realtime_block(:method_time) do
        result = RE_METHOD_CALL.match(method)
        raise MiqAeException::InvalidMethod, "invalid method calling syntax: [#{method}]" if result.nil?

        ns, klass, method_name = fqmethod2components(result[1])
        hashed_args            = method_parms_to_hash(result[2])

        method_result = invoke_method(ns, klass, method_name, hashed_args)
        self[collect] = method_result unless collect.blank?
      end
    end

    def process_method(field, _message, _args)
      process_method_raw(get_value(field), field['collect'])
    end

    def process_method_via_uri(uri)
      _scheme, _userinfo, _host, _port, _registry, path, _opaque, query, _fragment = MiqAeUri.split(uri)
      parts = path.split(PATH_SEPARATOR)
      parts.shift # Remove the leading blank piece
      method_name = parts.pop
      klass       = parts.pop
      ns          = parts.join(PATH_SEPARATOR)
      [ns, klass, method_name].each { |k| k&.downcase! }

      invoke_method(ns, klass, method_name, MiqAeUri.query2hash(query))
    end

    def fetch_state_attribute(name, required = false)
      if @workspace.persist_state_hash.key?(name)
        @workspace.persist_state_hash[name]
      elsif required
        raise MiqAeException::AttributeNotFound, "State var #{name} not found"
      end
    end

    def fetch_object_attribute(path, name, required = false)
      o = @workspace.get_obj_from_path(path)
      raise MiqAeException::ObjectNotFound, "Object Not Found for path=[#{path}]" if o.nil?

      if o.attributes.key?(name.downcase)
        o.attributes[name.downcase]
      elsif required
        raise MiqAeException::AttributeNotFound, "Attribute #{name} not found for object [#{path}]"
      end
    end

    def uri2value(uri, required = false)
      scheme, _userinfo, _host, _port, _registry, path, _opaque, _query, fragment = MiqAeUri.split(uri)

      if scheme == 'miqaedb'
        ns, klass, instance, attribute_name = MiqAePath.split(path, :has_attribute_name => true)
        ns = @workspace.overlay_namespace(scheme, uri, ns, klass, instance)
        o = MiqAeObject.new(@workspace, ns, klass, instance)
        message = fragment.nil? ? 'create' : fragment.downcase
        o.process_attributes(message)
        return o.attributes[attribute_name.downcase]
      end

      if scheme == 'miqaews'
        if path.starts_with?('!')
          return @workspace.current_message if path.downcase == '!current_message'

          raise MiqAeException::MethodNotFound, "Method [#{path}] Not Found for Current Object"
        end

        frags          = fragment.split('.')
        attribute_name = frags.shift
        methods        = frags

        value = if path.casecmp("STATE_VAR").zero?
                  fetch_state_attribute(attribute_name, required)
                else
                  fetch_object_attribute(path, attribute_name, required)
                end

        begin
          methods.each { |meth| value = call_method(value, meth) }
        rescue StandardError => err
          $miq_ae_logger.warn("Error during substitution: #{err.message}", :resource_id => @workspace.find_miq_request_id)
          return nil
        end
        return value
      end

      uri # if it was not processed, return the original uri
    end

    def substitute_value(value, _type = nil, required = false)
      Benchmark.current_realtime[:substitution_count] += 1
      Benchmark.realtime_block(:substitution_time) do
        unless value.nil?
          value = value.gsub(RE_SUBST) do |_s|
            subst   = uri2value($1, required)
            subst &&= subst.to_s
            # This encoding of relationship is not needed, until we can get a valid use case
            # Based on RFC 3986 Section 2.4 "When to Encode or Decode"
            # We are properly encoding when we send URL requests to external systems
            # or building an automate request
            # subst &&= URI.escape(subst, RE_URI_ESCAPE)  if type == :aetype_relationship
            subst
          end
        end
        return value
      end
    end

    def get_value(field, type = nil, required = false)
      field['datatype'] == MiqAeField::NULL_COALESCING_DATATYPE ? get_null_coalesced_value(field) : get_field_value(field, type, required)
    end

    def self.convert_boolean_value(value)
      return true  if value.to_s.downcase == 'true' || value == '1'

      return false if value.to_s.downcase == 'false' || value == '0'

      value
    end

    def self.convert_value_based_on_datatype(value, datatype)
      return value if value.blank? || datatype.nil?

      # Basic Types
      return convert_boolean_value(value)                    if datatype == 'boolean'
      return true                                            if datatype == 'TrueClass'
      return false                                           if datatype == 'FalseClass'
      return Time.parse(value).getlocal                      if 'time'.casecmp?(datatype)
      return value.to_sym                                    if 'symbol'.casecmp?(datatype)
      return value.to_i                                      if 'integer'.casecmp?(datatype) || datatype == 'Fixnum'
      return value.to_f                                      if 'float'.casecmp?(datatype)
      return value.gsub(/[\[\]]/, '').strip.split(/\s*,\s*/) if datatype == 'array' && value.class == String
      return decrypt_password(value) if datatype == 'password'

      if (service_model = "MiqAeMethodService::MiqAeService#{SM_LOOKUP[datatype]}".safe_constantize)
        return service_model.find(value)
      end

      raise MiqAeException::InvalidClass unless MiqAeField.available_datatypes.include?(datatype)

      # default datatype => 'string'
      value
    end

    private

    def call_method(obj, method)
      result = RE_METHOD_CALL.match(method)
      raise MiqAeException::InvalidMethod, "invalid method calling syntax: [#{method}]" if result.nil?

      if result[2]
        args = result[2]
        args = result[2][1..-1].to_sym if result[2][0] == ':'
        obj.send(result[1], args)
      else
        obj.send(result[1])
      end
    end

    def invoke_method(namespace, klass, method_name, args)
      aem = nil

      # No class means an instance method
      aem = @instance_methods[method_name.downcase] if klass.nil?
      # If not found in instance methods, look in class methods

      namespace ||= @namespace
      klass ||= @klass
      fq = MiqAeClass.fqname(namespace, klass)
      if aem.nil?
        cm  = @workspace.class_methods[fq.downcase]
        aem = cm[method_name] unless cm.nil?
      end

      aem = method_override(namespace, klass, method_name, aem)
      if aem.nil?
        method_aec = fetch_class(fq)
        aem = method_aec.ae_methods.detect { |c| c[:name] == method_name } unless method_aec.nil?
      end

      raise MiqAeException::MethodNotFound, "Method [#{method_name}] not found in class [#{fq}]" if aem.nil?

      begin
        @workspace.push_method(method_name)
        return MiqAeEngine::MiqAeMethod.invoke(self, aem, args)
      ensure
        @workspace.pop_method
      end
    end

    def method_override(namespace, klass, method_name, aem)
      ns = namespace.split('/')
      ns.shift
      updated_ns = @workspace.overlay_method(ns.join('/'), klass, method_name)
      if updated_ns != namespace
        cls = ::MiqAeClass.lookup_by_fqname("#{updated_ns}/#{klass}")
        aem = ::MiqAeMethod.lookup_by_class_id_and_name(cls.id, method_name) if cls
      end
      aem
    end

    def get_field_value(field, type = nil, required = false)
      value = field['value']
      value = field['default_value'] if value.blank?
      value = substitute_value(value, type, required) if field['substitute']
      value
    end

    def get_null_coalesced_value(field, type = nil)
      initial_value = field['value'] || field['default_value']
      return nil unless initial_value

      result = nil
      initial_value.split(NULL_COALESCING_OPERATOR).each do |value|
        result = resolve_value(value, type)
        break unless result.blank?
      end
      result
    end

    def resolve_value(value, type)
      current_value = value.strip
      substitute_value(current_value, type)
    rescue StandardError => err
      $miq_ae_logger.warn("#{err.message}, while evaluating :#{current_value} null coalecing attribute")
      nil
    end

    def self.decrypt_password(value)
      MiqAePassword.new(MiqAePassword.decrypt(value))
    rescue ManageIQ::Password::PasswordError => err
      $miq_ae_logger.error("Error decrypting password #{err.message}. Possible cause: Password value was encrypted with a different encryption key")
      raise
    end
    private_class_method :decrypt_password

    def process_assertion(field, message, args)
      Benchmark.current_realtime[:assertion_count] += 1
      Benchmark.realtime_block(:assertion_time) do
        assertion = get_value(field, :aetype_assertion, true)
        return if assertion.blank?

        $miq_ae_logger.info("Evaluating substituted assertion [#{assertion}]", :resource_id => @workspace.find_miq_request_id)

        begin
          _, _ = message, args # used by eval (?)
          assertion_result = eval(assertion)
        rescue SyntaxError => err
          $miq_ae_logger.error("Assertion had the following Syntax Error: '#{err.message}'")
          raise MiqAeException::AssertionFailure, "Syntax Error in Assertion: <#{assertion}>"
        rescue Exception => err # rubocop:disable Lint/RescueException
          $miq_ae_logger.error("'#{err.message}', evaluating assertion")
          raise MiqAeException::AssertionFailure, "Assertion Evaluation Failed: <#{assertion}>"
        end

        raise MiqAeException::AssertionFailure, "Assertion Failed: <#{assertion}>" unless assertion_result
      end
    end

    def process_attribute(field, _message, _args, value = nil)
      Benchmark.current_realtime[:attribute_count] += 1
      Benchmark.realtime_block(:attribute_time) do
        value = get_value(field) if value.nil?
        value = MiqAeObject.convert_value_based_on_datatype(value, field['datatype'])
        @attributes[field['name'].downcase] = value unless value.nil?
        process_collect(field['collect'], nil) if field['collect'].present?
      end
    end

    def process_relationship_raw(relationship, message, args, name, collect)
      return if relationship.blank? || relationship.lstrip[0, 1] == '#'

      Benchmark.current_realtime[:relationship_count] += 1

      scheme, userinfo, host, port, registry, path, opaque, query, fragment = MiqAeUri.split(relationship, 'miqaedb')
      fragment = message                   if fragment.nil?
      query    = MiqAeUri.hash2query(args) if query.nil?
      relationship = MiqAeUri.join(scheme, userinfo, host, port, registry, path, opaque, query, fragment)

      miq_request_id = @workspace.find_miq_request_id
      $miq_ae_logger.info("Following Relationship [#{relationship}]", :resource_id => miq_request_id)

      if relationship.include?('*')
        rels = []
        wildcard_expand(relationship).each do |r|
          Benchmark.current_realtime[:relationship_followed_count] += 1
          rels << @workspace.instantiate(r, @workspace.ae_user, self)
        end
      else
        Benchmark.current_realtime[:relationship_followed_count] += 1
        rels = @workspace.instantiate(relationship, @workspace.ae_user, self)
      end
      process_collects(collect, rels)
      @rels[name] = rels

      $miq_ae_logger.info("Followed  Relationship [#{relationship}]", :resource_id => miq_request_id)
    end

    def process_collects(what, rels)
      return if rels.nil? || what.nil?

      what.to_s.split(COLLECT_SEPARATOR).each { |expr| process_collect(expr, rels) }
    end

    def process_collect(expr, rels)
      Benchmark.current_realtime[:collect_count] += 1
      Benchmark.realtime_block(:collect_time) do
        if    (result = RE_COLLECT_ARRAY.match(expr))
          process_collect_array(expr, rels, result)
        elsif (result = RE_COLLECT_HASH.match(expr))
          process_collect_hash(expr, rels, result)
        elsif (result = RE_COLLECT_STRING.match(expr))
          process_collect_string(expr, rels, result)
        else
          raise MiqAeException::InvalidCollection, "invalid collect item: <#{expr}>"
        end
      end.first
    end

    def process_collect_set_attribute(key, value)
      key = @current_field['name'] if key.nil? && @current_field.kind_of?(Hash)
      return value if key.nil?

      parts = key.split(PATH_SEPARATOR)
      left = parts.pop
      if parts.empty?
        obj = self
      else
        path = parts.first.blank? ? PATH_SEPARATOR : parts.join(PATH_SEPARATOR)
        obj = @workspace.get_obj_from_path(path)
      end
      obj.attributes[left.downcase] = value unless obj.nil?
    end

    def process_collect_array(_expr, rels, result)
      lh       = result[1].strip unless result[1].nil?
      contents = result[2].strip
      method   = result[3].strip.downcase unless result[3].nil?

      elems = []
      contents.split(ENUM_SEPARATOR).each do |e|
        elem = e.strip
        elems << elem unless elem.empty?
      end

      array = []
      if rels.kind_of?(Array)
        rels.collect { |r| elems.each { |e| array << r[e] } }
      elsif rels.nil?
        elems.each { |e| array << classify_value(e) }
      else
        elems.each { |e| array << rels[e] }
      end

      return if array.length.zero?

      process_collect_set_attribute(lh, array_value(array, method))
    end

    def array_value(array, method)
      return array if array.nil? || array.compact.empty?

      case method
      when 'rsort'                  then array.sort.reverse
      when 'sort'                   then array.sort
      when 'reverse'                then array.reverse
      when 'count'                  then array.length
      when 'min', 'minimum'         then array.min
      when 'max', 'maximum'         then array.max
      when 'sum'                    then array.sum
      when 'mean', 'average', 'avg' then array.mean
      when 'variance'               then array.variance
      when 'stddev', 'stdev'        then array.stddev
      else array
      end
    end

    def process_collect_hash(expr, rels, result)
      lh       = result[1].strip unless result[1].nil?
      contents = result[2].strip

      hash = {}
      hashes = contents.split(ENUM_SEPARATOR)
      hashes.each do |hash_contents|
        hash_details = hash_contents.split('=>')
        raise MiqAeException::InvalidCollection, "invalid hash in collect item <#{expr}>" if hash_details.length != 2

        left = hash_details[0].strip

        if left[0, 1] == ':'
          ltype = :symbol
          left  = left[1..-1].to_sym
        elsif ["\"", "\'"].include?(left[0, 1])
          ltype = :string
          left  = left[1..-2]
        else
          ltype = :value
        end

        right = hash_details[1].strip

        if rels.kind_of?(Array)
          if ltype == :value
            rels.collect { |r| hash[r[left]] = r[right] }
          else
            rels.collect { |r| hash[left]    = r[right] }
          end
        elsif rels.nil?
          if ltype == :value
            hash[self[left]] = self[right]
          else
            hash[left]       = self[right]
          end
        elsif ltype == :value
          hash[rels[left]] = rels[right]
        else
          hash[left]       = rels[right]
        end
      end
      process_collect_set_attribute(lh, hash) unless hash.length.zero?
    end

    def process_collect_string(_expr, rels, result)
      cattr   = result[1].strip unless result[1].nil?
      name    = result[2].strip
      cattr ||= name unless rels.nil? # Set cattr to name ONLY if coming from relationship

      value = if rels.kind_of?(Array)
                rels.collect { |r| r[name] }
              elsif rels.nil?
                self[name]
              else
                rels[name]
              end
      process_collect_set_attribute(cattr, value)
    end

    def wildcard_expand(rel)
      return [] if rel.blank?

      scheme, userinfo, host, port, registry, path, opaque, query, fragment = MiqAeUri.split(rel)
      return [rel] unless MiqAePath.wildcard?(path)

      MiqAeInstance.search(path[1..-1]).collect do |i|
        path = i.fqname
        MiqAeUri.join(scheme, userinfo, host, port, registry, path, opaque, query, fragment)
      end.sort
    end

    def fqmethod2components(str)
      parts = str.split(METHOD_SEPARATOR)
      raise MiqAeException::InvalidMethod, "invalid method name: [#{str}]" if parts.length > 2

      method_name = parts.pop
      klass       = nil
      ns          = nil

      unless parts.length.zero?
        parts = parts.pop.split(PATH_SEPARATOR)
        klass = parts.pop
        ns    = parts.join(PATH_SEPARATOR) unless parts.length.zero?
      end

      [ns, klass, method_name].each { |k| k&.downcase! }

      return ns, klass, method_name
    end

    def method_parms_to_hash(str)
      h = {}

      while (result = RE_HASH.match(str))
        key    = result[1]
        value  = result[2]
        h[key] = classify_value(value)
        str    = result.post_match
      end

      h
    end

    def classify_value(value)
      if value.starts_with?("'")
        raise "Unmatched Single Quoted String <#{e}> in Collect" unless value.ends_with?("'")
        value[1..-2]
      elsif value.starts_with?("\"")
        raise "Unmatched Double Quoted String <#{e}> in Collect" unless value.ends_with?("\"")
        value[1..-2]
      elsif /^[+-]?[0-9]+\s*$/.match?(value)
        value.to_i
      elsif /^[-+]?[0-9]+\.[0-9]+\s*$/.match?(value)
        value.to_f
      else
        self[value]
      end
    end

    def attribute_for_vmdb_object?(klass, value)
      klass.safe_constantize && value.to_i.nonzero?
    end
  end
end