ManageIQ/manageiq-api-common

View on GitHub
lib/insights/api/common/filter.rb

Summary

Maintainability
A
35 mins
Test Coverage
module Insights
  module API
    module Common
      class Filter
        INTEGER_COMPARISON_KEYWORDS = ["eq", "not_eq", "gt", "gte", "lt", "lte", "nil", "not_nil"].freeze
        STRING_COMPARISON_KEYWORDS  = ["contains", "contains_i", "eq", "not_eq", "eq_i", "not_eq_i", "starts_with", "starts_with_i", "ends_with", "ends_with_i", "nil", "not_nil"].freeze
        ALL_COMPARISON_KEYWORDS     = (INTEGER_COMPARISON_KEYWORDS + STRING_COMPARISON_KEYWORDS).uniq.freeze

        attr_reader :apply, :arel_table, :api_doc_definition, :extra_filterable_attributes, :model

        # Instantiates a new Filter object
        #
        # == Parameters:
        # model::
        #   An AR model that acts as the base collection to be filtered
        # raw_filter::
        #   The filter from the request query string
        # api_doc_definition::
        #   The documented object definition from the OpenAPI doc
        # extra_filterable_attributes::
        #   Attributes that can be used for filtering but are not documented in the OpenAPI doc.  Something like `{"undocumented_column" => {"type" => "string"}}`
        #
        # == Returns:
        # A new Filter object, call #apply to get the filtered set of results.
        #
        def initialize(model, raw_filter, api_doc_definition, extra_filterable_attributes = {})
          @raw_filter                  = raw_filter
          @api_doc_definition          = api_doc_definition
          @arel_table                  = model.arel_table
          @extra_filterable_attributes = extra_filterable_attributes
          @model                       = model
        end

        def query
          @query ||= filter_associations.present? ? model.left_outer_joins(filter_associations) : model
        end
        attr_writer :query

        def apply
          return query if @raw_filter.blank?

          self.class.compact_filter(@raw_filter).each do |k, v|
            next unless (attribute = attribute_for_key(k))

            if attribute["type"] == "string"
              type = determine_string_attribute_type(attribute)
              send(type, k, v)
            else
              errors << "unsupported attribute type for: #{k}"
            end
          end

          raise(Insights::API::Common::Filter::Error, errors.join(", ")) unless errors.blank?
          query
        end

        # Compact filters to support association filtering
        #
        #   Input:  {"source_type"=>{"name"=>{"eq"=>"rhev"}}}
        #   Output: {"source_type.name"=>{"eq"=>"rhev"}}
        #
        #   Input:  {"source_type"=>{"name"=>{"eq"=>["openstack", "openshift"]}}}
        #   Output: {"source_type.name"=>{"eq"=>["openstack", "openshift"]}}
        #
        def self.compact_filter(filter)
          result = {}
          return result if filter.blank?
          return filter unless filter.kind_of?(Hash) || filter.kind_of?(ActionController::Parameters)

          filter = Hash(filter.permit!) if filter.kind_of?(ActionController::Parameters)

          filter.each do |ak, av|
            if av.kind_of?(Hash)
              av.each do |atk, atv|
                if !ALL_COMPARISON_KEYWORDS.include?(atk)
                  result["#{ak}.#{atk}"] = atv
                else
                  result[ak] = av
                end
              end
            else
              result[ak] = av
            end
          end
          result
        end

        def self.association_attribute_properties(api_doc_definitions, raw_filter)
          return {} if raw_filter.blank?

          association_attributes = compact_filter(raw_filter).keys.select { |key| key.include?('.') }.compact.uniq
          return {} if association_attributes.blank?

          association_attributes.each_with_object({}) do |key, hash|
            association, attr = key.split('.')
            hash[key] = api_doc_definitions[association.singularize.classify].properties[attr.to_s]
          end
        end

        private

        delegate(:arel_attribute, :to => :model)

        class Error < ArgumentError; end

        def key_model_attribute(key)
          if key.include?('.')
            association, attr = key.split('.')
            [association.classify.constantize, attr]
          else
            [model, key]
          end
        end

        def model_arel_attribute(key)
          key_model, attr = key_model_attribute(key)
          key_model.arel_attribute(attr)
        end

        def model_arel_table(key)
          key_model, attr = key_model_attribute(key)
          key_model.arel_table
        end

        def filter_associations
          return nil if @raw_filter.blank?

          @filter_associations ||= begin
            self.class.compact_filter(@raw_filter).keys.collect do |key|
              next unless key.include?('.')

              key.split('.').first.to_sym
            end.compact.uniq
          end
        end

        def attribute_for_key(key)
          attribute = api_doc_definition.properties[key.to_s]
          attribute ||= extra_filterable_attributes[key.to_s]
          return attribute if attribute
          errors << "found unpermitted parameter: #{key}"
          nil
        end

        def determine_string_attribute_type(attribute)
          return :timestamp if attribute["format"] == "date-time"
          return :integer if attribute["pattern"] == /^\d+$/
          :string
        end

        def errors
          @errors ||= []
        end

        def string(k, val)
          if val.kind_of?(Hash)
            val.each do |comparator, value|
              add_filter(comparator, STRING_COMPARISON_KEYWORDS, k, value)
            end
          else
            add_filter("eq", STRING_COMPARISON_KEYWORDS, k, val)
          end
        end

        def add_filter(requested_comparator, allowed_comparators, key, value)
          return unless attribute = attribute_for_key(key)
          type = determine_string_attribute_type(attribute)

          if requested_comparator.in?(["nil", "not_nil"])
            send("comparator_#{requested_comparator}", key, value)
          elsif requested_comparator.in?(allowed_comparators)
            value = parse_datetime(value) if type == :datetime
            return if value.nil?
            send("comparator_#{requested_comparator}", key, value)
          else
            errors << "unsupported #{type} comparator: #{requested_comparator}"
          end
        end

        def self.build_filtered_scope(scope, api_version, klass_name, filter)
          return scope unless filter

          openapi_doc = ::Insights::API::Common::OpenApi::Docs.instance[api_version]
          openapi_schema_name, = ::Insights::API::Common::GraphQL::Generator.openapi_schema(openapi_doc, klass_name)

          action_parameters = ActionController::Parameters.new(filter)
          definitions = openapi_doc.definitions

          association_attribute_properties = association_attribute_properties(definitions, action_parameters)

          new(scope, action_parameters, definitions[openapi_schema_name], association_attribute_properties).apply
        end

        def timestamp(k, val)
          if val.kind_of?(Hash)
            val.each do |comparator, value|
              add_filter(comparator, INTEGER_COMPARISON_KEYWORDS, k, value)
            end
          else
            add_filter("eq", INTEGER_COMPARISON_KEYWORDS, k, val)
          end
        end

        def parse_datetime(value)
          return value.collect { |i| parse_datetime(i, ) } if value.kind_of?(Array)

          DateTime.parse(value)
        rescue ArgumentError
          errors << "invalid timestamp: #{value}"
          return nil
        end

        def integer(k, val)
          if val.kind_of?(Hash)
            val.each do |comparator, value|
              add_filter(comparator, INTEGER_COMPARISON_KEYWORDS, k, value)
            end
          else
            add_filter("eq", INTEGER_COMPARISON_KEYWORDS, k, val)
          end
        end

        def arel_lower(key)
          Arel::Nodes::NamedFunction.new("LOWER", [model_arel_attribute(key)])
        end

        def comparator_contains(key, value)
          return value.each { |v| comparator_contains(key, v) } if value.kind_of?(Array)

          self.query = query.where(model_arel_attribute(key).matches("%#{query.sanitize_sql_like(value)}%", nil, true))
        end

        def comparator_contains_i(key, value)
          return value.each { |v| comparator_contains_i(key, v) } if value.kind_of?(Array)

          self.query = query.where(model_arel_table(key).grouping(arel_lower(key).matches("%#{query.sanitize_sql_like(value.downcase)}%", nil, true)))
        end

        def comparator_starts_with(key, value)
          self.query = query.where(model_arel_attribute(key).matches("#{query.sanitize_sql_like(value)}%", nil, true))
        end

        def comparator_starts_with_i(key, value)
          self.query = query.where(model_arel_table(key).grouping(arel_lower(key).matches("#{query.sanitize_sql_like(value.downcase)}%", nil, true)))
        end

        def comparator_ends_with(key, value)
          self.query = query.where(model_arel_attribute(key).matches("%#{query.sanitize_sql_like(value)}", nil, true))
        end

        def comparator_ends_with_i(key, value)
          self.query = query.where(model_arel_table(key).grouping(arel_lower(key).matches("%#{query.sanitize_sql_like(value.downcase)}", nil, true)))
        end

        def comparator_eq(key, value)
          self.query = query.where(model_arel_attribute(key).eq_any(Array(value)))
        end

        def comparator_not_eq(key, value)
          self.query = query.where.not(model_arel_attribute(key).eq_any(Array(value)))
        end

        def comparator_not_eq_i(key, value)
          values = Array(value).map { |v| query.sanitize_sql_like(v.downcase) }

          self.query = query.where.not(model_arel_table(key).grouping(arel_lower(key).matches_any(values)))
        end

        def comparator_eq_i(key, value)
          values = Array(value).map { |v| query.sanitize_sql_like(v.downcase) }

          self.query = query.where(model_arel_table(key).grouping(arel_lower(key).matches_any(values)))
        end

        def comparator_gt(key, value)
          self.query = query.where(model_arel_attribute(key).gt(value))
        end

        def comparator_gte(key, value)
          self.query = query.where(model_arel_attribute(key).gteq(value))
        end

        def comparator_lt(key, value)
          self.query = query.where(model_arel_attribute(key).lt(value))
        end

        def comparator_lte(key, value)
          self.query = query.where(model_arel_attribute(key).lteq(value))
        end

        def comparator_nil(key, _value = nil)
          self.query = query.where(model_arel_attribute(key).eq(nil))
        end

        def comparator_not_nil(key, _value = nil)
          self.query = query.where.not(model_arel_attribute(key).eq(nil))
        end
      end
    end
  end
end