Katello/katello

View on GitHub
app/models/katello/rpm.rb

Summary

Maintainability
C
7 hrs
Test Coverage
module Katello
  class Rpm < Katello::Model
    include Concerns::PulpDatabaseUnit

    CONTENT_TYPE = 'rpm'.freeze
    has_many :content_facet_applicable_rpms, :class_name => "Katello::ContentFacetApplicableRpm",
             :dependent => :destroy, :inverse_of => :rpm
    has_many :content_facets, :through => :content_facet_applicable_rpms, :class_name => "Katello::Host::ContentFacet"
    has_many :module_stream_rpms, class_name: "Katello::ModuleStreamRpm", inverse_of: :rpm, dependent: :destroy
    has_many :module_streams, :through => :module_stream_rpms
    scoped_search :on => :id, :complete_value => true
    scoped_search :on => :name, :complete_value => true
    scoped_search :on => :version, :complete_value => true, :ext_method => :scoped_search_version
    scoped_search :on => :release, :complete_value => true, :ext_method => :scoped_search_release
    scoped_search :on => :arch, :complete_value => true
    scoped_search :on => :epoch, :complete_value => true
    scoped_search :on => :evr, :ext_method => :scoped_search_evr, :only_explicit => true
    scoped_search :on => :filename, :complete_value => true
    scoped_search :on => :sourcerpm, :complete_value => true
    scoped_search :on => :nvra, :complete_value => true
    scoped_search :on => :modular, :complete_value => true, :only_explicit => true
    scoped_search :on => :checksum

    scope :modular, -> { where(modular: true) }
    scope :non_modular, -> { where(modular: false) }

    def self.default_sort
      order(:name).order(:epoch).order(:version_sortable).order(:release_sortable)
    end

    def self.content_facet_association_class
      ContentFacetApplicableRpm
    end

    def self.scoped_search_version(_key, operator, value)
      self.scoped_search_sortable('version', operator, value)
    end

    def self.scoped_search_release(_key, operator, value)
      self.scoped_search_sortable('release', operator, value)
    end

    def self.scoped_search_sortable(column, operator, value)
      if ['>', '>=', '<', '<='].include?(operator)
        conditions = "#{self.table_name}.#{column}_sortable COLLATE \"C\" #{operator} ? COLLATE \"C\""
        parameter = Util::Package.sortable_version(value)
        return { :conditions => conditions, :parameter => [parameter] }
      end

      # Use the default behavior for all other operators.
      # Unfortunately there is no way to call the default behavior from here, so
      # we replicate the default behavior from sql_test() in
      # https://github.com/wvanbergen/scoped_search/blob/master/lib/scoped_search/query_builder.rb
      if ['LIKE', 'NOT LIKE'].include?(operator)
        conditions = "#{self.table_name}.#{column} #{operator} ?"
        parameter = (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%')
        return { :conditions => conditions, :parameter => [parameter] }
      elsif ['IN', 'NOT IN'].include?(operator)
        conditions = "#{self.table_name}.#{column} #{operator} (#{value.split(',').collect { '?' }.join(',')})"
        parameters = value.split(',').collect { |v| v.strip }
        return { :conditions => conditions, :parameter => parameters }
      else
        conditions = "#{self.table_name}.#{column} #{operator} ?"
        parameter = value
        return { :conditions => conditions, :parameter => [parameter] }
      end
    end

    def self.scoped_search_evr(_key, operator, value)
      if ['=', '<>'].include?(operator)
        return self.scoped_search_evr_equal(operator, value)
      elsif ['IN', 'NOT IN'].include?(operator)
        return self.scoped_search_evr_in(operator, value)
      elsif ['LIKE', 'NOT LIKE'].include?(operator)
        return self.scoped_search_evr_like(operator, value)
      elsif ['>', '>=', '<', '<='].include?(operator)
        return self.scoped_search_evr_compare(operator, value)
      else
        return {}
      end
    end

    def self.scoped_search_evr_equal(operator, value)
      joiner = (operator == '=' ? 'AND' : 'OR')
      evr = Util::Package.parse_evr(value)
      (e, v, r) = [evr[:epoch], evr[:version], evr[:release]]
      e = e.to_i # nil or blank becomes 0
      conditions = "CAST(#{self.table_name}.epoch AS INT) #{operator} ?"
      parameters = [e]
      unless v.nil? || v.blank?
        conditions += " #{joiner} #{self.table_name}.version #{operator} ?"
        parameters += [v]
      end
      unless r.nil? || r.blank?
        conditions += " #{joiner} #{self.table_name}.release #{operator} ?"
        parameters += [r]
      end
      return { :conditions => conditions, :parameter => parameters }
    end

    def self.scoped_search_evr_in(operator, value)
      op = (operator == 'IN' ? '=' : '<>')
      joiner1 = (operator == 'IN' ? 'AND' : 'OR')
      joiner2 = (operator == 'IN' ? 'OR' : 'AND')
      conditions = []
      parameters = []
      value.split(',').collect { |v| v.strip }.each do |val|
        evr = Util::Package.parse_evr(val)
        (e, v, r) = [evr[:epoch], evr[:version], evr[:release]]
        e = e.to_i # nil or blank becomes 0
        condition = "CAST(#{self.table_name}.epoch AS INT) #{op} ?"
        parameters += [e]
        unless v.nil? || v.blank?
          condition += " #{joiner1} #{self.table_name}.version #{op} ?"
          parameters += [v]
        end
        unless r.nil? || r.blank?
          condition += " #{joiner1} #{self.table_name}.release #{op} ?"
          parameters += [r]
        end
        conditions += ["(#{condition})"]
      end
      return { :conditions => conditions.join(" #{joiner2} "), :parameter => parameters }
    end

    def self.scoped_search_evr_like(operator, value)
      val = (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%')
      evr = Util::Package.parse_evr(val)
      (e, v, r) = [evr[:epoch], evr[:version], evr[:release]]
      conditions = []
      parameters = []
      unless e.nil? || e.blank?
        conditions += ["CAST(#{self.table_name}.epoch AS VARCHAR(10)) #{operator} ?"]
        parameters += [e]
      end
      unless v.nil? || v.blank?
        conditions += ["#{self.table_name}.version #{operator} ?"]
        parameters += [v]
      end
      unless r.nil? || r.blank?
        conditions += ["#{self.table_name}.release #{operator} ?"]
        parameters += [r]
      end
      return {} if conditions.empty?
      joiner = (operator == 'LIKE' ? 'AND' : 'OR')
      return { :conditions => conditions.join(" #{joiner} "), :parameter => parameters }
    end

    def self.scoped_search_evr_compare(operator, value)
      evr = Util::Package.parse_evr(value)
      (e, v, r) = [evr[:epoch], evr[:version], evr[:release]]
      e = e.to_i # nil or blank becomes 0
      conditions = ''
      if v.nil? || v.blank?
        conditions = "CAST(#{self.table_name}.epoch AS INT) #{operator} ?"
        parameters = [e]
      else
        sv = Util::Package.sortable_version(v)
        if r.nil? || r.blank?
          conditions = "#{self.table_name}.version_sortable COLLATE \"C\" #{operator} ? COLLATE \"C\""
          parameters = [sv]
        else
          conditions =
            "#{self.table_name}.version_sortable COLLATE \"C\" #{operator[0]} ? COLLATE \"C\" OR " \
            "(#{self.table_name}.version_sortable = ? AND " \
             "#{self.table_name}.release_sortable COLLATE \"C\" #{operator} ? COLLATE \"C\")"
          sv = Util::Package.sortable_version(v)
          parameters = [sv, sv, Util::Package.sortable_version(r)]
        end
        conditions = "CAST(#{self.table_name}.epoch AS INT) #{operator[0]} ? OR (CAST(#{self.table_name}.epoch AS INT) = ? AND (#{conditions}))"
        parameters = [e, e] + parameters
      end
      return { :conditions => conditions, :parameter => parameters }
    end

    def self.search_version_range(min = nil, max = nil)
      query = self.all
      query = Katello::Util::PackageFilter.new(query, min, Katello::Util::PackageFilter::GREATER_THAN).results if min.present?
      query = Katello::Util::PackageFilter.new(query, max, Katello::Util::PackageFilter::LESS_THAN).results if max.present?
      query
    end

    def self.search_version_equal(version)
      Katello::Util::PackageFilter.new(self, version, Katello::Util::PackageFilter::EQUAL).results
    end

    def self.total_for_repositories(repos)
      self.in_repositories(repos).count
    end

    def nvrea
      Util::Package.build_nvrea(self.attributes.with_indifferent_access, false)
    end

    def build_nvra
      Util::Package.build_nvra(self.attributes.with_indifferent_access)
    end

    def hosts_applicable(org_id = nil)
      if org_id.present?
        self.content_facets.joins(:host).where("#{::Host.table_name}.organization_id" => org_id)
      else
        self.content_facets.joins(:host)
      end
    end

    def hosts_available(org_id = nil)
      self.hosts_applicable(org_id).joins("INNER JOIN #{Katello::RepositoryRpm.table_name} on \
        #{Katello::RepositoryRpm.table_name}.rpm_id = #{self.id}").joins(:content_facet_repositories).
        where("#{Katello::ContentFacetRepository.table_name}.repository_id = #{Katello::RepositoryRpm.table_name}.repository_id").uniq
    end

    def self.applicable_to_hosts(hosts)
      self.joins(:content_facets).
        where("#{Katello::Host::ContentFacet.table_name}.host_id" => hosts).distinct
    end

    # Return RPMs that are not installed on a host, but could be installed
    # the word 'installable' has a different meaning here than elsewhere
    def self.yum_installable_for_host(host)
      repos = host.content_facet.bound_repositories.pluck(:id)
      Katello::Rpm.in_repositories(repos).where.not(name: host.installed_packages.pluck(:name)).order(:name)
    end

    def self.latest(relation)
      # There are several different ways to implement this:

      # 1) Use a LEFT OUTER JOIN to join the query with itself on name=name,
      #    arch=arch, evr<evr, then select unmatched rows.
      #    In PostgreSQL 9.2 populated with real data, EXPLAIN shows
      #    cost=339901.07..381834.02 for the SQL generated by this when a
      #    package search is performed with a content_view_version_id filter.
      #    Unfortunately, this requires writing raw SQL for the JOIN and using
      #    to_sql, which may be somewhat brittle.  For example, be aware of
      #    quirks like https://github.com/rails/rails/issues/18379
      return relation.joins(
        "LEFT OUTER JOIN (#{relation.to_sql}) AS katello_rpms2 ON " \
        'katello_rpms.name = katello_rpms2.name AND katello_rpms.arch = katello_rpms2.arch AND ' \
        '(CAST(katello_rpms.epoch AS INT) < CAST(katello_rpms2.epoch AS INT) OR ' \
        ' (CAST(katello_rpms.epoch AS INT) = CAST(katello_rpms2.epoch AS INT) AND ' \
        '  (katello_rpms.version_sortable COLLATE "C" < katello_rpms2.version_sortable COLLATE "C" OR ' \
        '   (katello_rpms.version_sortable = katello_rpms2.version_sortable AND ' \
        '    katello_rpms.release_sortable COLLATE "C" < katello_rpms2.release_sortable COLLATE "C"))))'
      ).where('katello_rpms2.release_sortable IS NULL')

      # 2) Use GROUP BY with MAX() and several INNER JOINs to identify the
      #    latest evr, then INNER JOIN again to get complete rows.
      #    Like #1, this must also be written using raw SQL and to_sql.
      #    cost=677566.03..704889.89
      #max_epochs = relation.
      #  group(:name, :arch).
      #  select(:name, :arch, 'MAX(CAST(katello_rpms.epoch AS INT)) AS max_epoch')
      #max_versions = relation.
      #  joins(
      #    "INNER JOIN (#{max_epochs.to_sql}) katello_rpms2 ON " \
      #    'katello_rpms.name = katello_rpms2.name AND katello_rpms.arch = katello_rpms2.arch AND ' \
      #    'CAST(katello_rpms.epoch AS INT) = katello_rpms2.max_epoch'
      #  ).
      #  group(:name, :arch, :epoch).
      #  select(:name, :arch, :epoch, 'MAX(katello_rpms.version_sortable COLLATE "C") AS max_version')
      #max_releases = relation.
      #  joins(
      #    "INNER JOIN (#{max_versions.to_sql}) katello_rpms2 ON " \
      #    'katello_rpms.name = katello_rpms2.name AND katello_rpms.arch = katello_rpms2.arch AND ' \
      #    'CAST(katello_rpms.epoch AS INT) = CAST(katello_rpms2.epoch AS INT) AND ' \
      #    'katello_rpms.version_sortable = katello_rpms2.max_version'
      #  ).
      #  group(:name, :arch, :epoch, :version_sortable).
      #  select(:name, :arch, :epoch, :version_sortable, 'MAX(katello_rpms.release_sortable COLLATE "C") AS max_release')
      #return relation.
      #  joins(
      #    "INNER JOIN (#{max_releases.to_sql}) katello_rpms2 ON " \
      #    'katello_rpms.name = katello_rpms2.name AND katello_rpms.arch = katello_rpms2.arch AND ' \
      #    'CAST(katello_rpms.epoch AS INT) = CAST(katello_rpms2.epoch AS INT) AND ' \
      #    'katello_rpms.version_sortable = katello_rpms2.version_sortable AND ' \
      #    'katello_rpms.release_sortable = katello_rpms2.max_release'
      #  )

      # 3) Like #1, but use a Common Table Expression instead of a
      #    LEFT OUTER JOIN to join the query with itself.
      #    This seems to be faster than #1 in some cases and slower in others
      #    depending on optimizer behavior.
      #    For the same test case as used for #1, cost=404240.71..405796.40
      #    However, this is not supported in all versions of all databases
      #    (e.g. it only works in MySQL >=8.0).
      #    This can be written using either raw SQL or Arel.
      #
      # 3a) Raw SQL
      #cte_sql = relation.select('katello_rpms.*, CAST(katello_rpms.epoch AS INT) AS epoch_sortable').to_sql
      #return Rpm.where('katello_rpms.id IN (' \
      #  "WITH katello_rpms AS (#{cte_sql}) " \
      #  'SELECT katello_rpms.id from katello_rpms LEFT OUTER JOIN katello_rpms AS katello_rpms2 ON ' \
      #  'katello_rpms.name = katello_rpms2.name AND katello_rpms.arch = katello_rpms2.arch AND ' \
      #  '(katello_rpms.epoch_sortable < katello_rpms2.epoch_sortable OR ' \
      #  ' (katello_rpms.epoch_sortable = katello_rpms2.epoch_sortable AND ' \
      #  '  (katello_rpms.version_sortable COLLATE "C" < katello_rpms2.version_sortable COLLATE "C" OR ' \
      #  '   (katello_rpms.version_sortable = katello_rpms2.version_sortable AND ' \
      #  '    katello_rpms.release_sortable COLLATE "C" < katello_rpms2.release_sortable COLLATE "C")))) ' \
      #  'WHERE katello_rpms2.release_sortable IS NULL' \
      #')')
      #
      # 3b) Arel
      #     Unlike #1, #2, and #3a, this does not require raw SQL or to_sql.
      #     Unfortunately this doesn't work reliably in Rails 4.2 because
      #     relation.unscoped.where(relation.arel_table[:id].in(relation.arel))
      #     looses the prepared statement parameter values.  See:
      #     https://github.com/rails/rails/issues/13686
      ## Convert current query to Arel for use in a CTE
      #orig_query = relation.arel
      ## Add another column to the original query: CAST(epoch AS INT) AS epoch_sortable
      #orig_table = relation.arel_table
      #orig_query = orig_query.project(Arel::Nodes::NamedFunction.new('CAST', [orig_table[:epoch].as('INT')], 'epoch_sortable'))
      ## Create two names that will both refer to the existing query in the CTE
      #cte1 = Arel::Table.new(:katello_rpms)
      #cte2 = Arel::Table.new(:katello_rpms2)
      ## Create a new Arel query and use a CTE to JOIN the original query with
      ## itself and select the latest evr for each name/arch
      #new_query = cte1.project(cte1[:id]).with(Arel::Nodes::As.new(cte1, orig_query)).
      #  join(Arel::Nodes::As.new(cte1, cte2), Arel::Nodes::OuterJoin).on(
      #    cte1[:name].eq(cte2[:name]).and(cte1[:arch].eq(cte2[:arch])).and(
      #      cte1[:epoch_sortable].lt(cte2[:epoch_sortable]).or(
      #        cte1[:epoch_sortable].eq(cte2[:epoch_sortable]).and(
      #          Arel::Nodes::SqlLiteral.new('katello_rpms.version_sortable COLLATE "C"').lt(Arel::Nodes::SqlLiteral.new('katello_rpms2.version_sortable COLLATE "C"')).or(
      #            cte1[:version_sortable].eq(cte2[:version_sortable]).and(
      #              Arel::Nodes::SqlLiteral.new('katello_rpms.release_sortable COLLATE "C"').lt(Arel::Nodes::SqlLiteral.new('katello_rpms2.release_sortable COLLATE "C"'))
      #            )
      #          )
      #        )
      #      )
      #    )
      #  ).
      #  where(cte2[:release_sortable].eq(nil))
      ## Wrap the new Arel query in a new ActiveRecord::Relation
      #return relation.unscoped.where(orig_table[:id].in(new_query))
    end
  end
end