ForestAdmin/forest-rails

View on GitHub
app/services/forest_liana/filters_parser.rb

Summary

Maintainability
B
5 hrs
Test Coverage
A
97%
module ForestLiana
  class FiltersParser
    AGGREGATOR_OPERATOR = %w(and or)

    def initialize(filters, resource, timezone, params = nil)
      @filters = filters
      @params = params
      @resource = resource
      @operator_date_parser = OperatorDateIntervalParser.new(timezone)
      @joins = []
    end

    def apply_filters
      return @resource unless @filters

      where = parse_aggregation(@filters)
      return @resource unless where

      @joins.each do |join|
        current_resource = @resource.reflect_on_association(join.name).klass
        current_resource.include(ArelHelpers::Aliases)
        current_resource.aliased_as(join.name) do |aliased_resource|
          @resource = @resource.joins(ArelHelpers.join_association(@resource, join.name, Arel::Nodes::OuterJoin, aliases: [aliased_resource]))
        end
      end

      @resource.where(where)
    end

    def parse_aggregation(node)
      ensure_valid_aggregation(node)

      return parse_condition(node) unless node['aggregator']

      conditions = []
      node['conditions'].each do |condition|
        conditions.push(parse_aggregation(condition))
      end

      operator = parse_aggregation_operator(node['aggregator'])

      conditions.empty? ? nil : "(#{conditions.join(" #{operator} ")})"
    end

    def parse_condition(condition)
      where = parse_condition_without_smart_field(condition)

      field_name = condition['field']

      if ForestLiana::SchemaHelper.is_smart_field?(@resource, field_name)
        schema = ForestLiana.schema_for_resource(@resource)
        field_schema = schema.fields.find do |field|
          field[:field].to_s == field_name
        end

        unless field_schema.try(:[], :filter)
          raise ForestLiana::Errors::NotImplementedMethodError.new("method filter on smart field '#{field_name}' not found")
        end

        return field_schema[:filter].call(condition, where)
      end

      where
    end

    def get_association_field_and_resource(field_name)
      if is_belongs_to(field_name)
        association = field_name.partition(':').first.to_sym
        association_field = field_name.partition(':').last

        unless @resource.reflect_on_association(association)
          raise ForestLiana::Errors::HTTP422Error.new("Association '#{association}' not found")
        end

        current_resource = @resource.reflect_on_association(association).klass

        return association_field, current_resource
      else
        return field_name, @resource
      end
    end

    def parse_condition_without_smart_field(condition)
      ensure_valid_condition(condition)

      operator = condition['operator']
      value = condition['value']
      field_name = condition['field']

      if @operator_date_parser.is_date_operator?(operator)
        condition = @operator_date_parser.get_date_filter(operator, value)
        return "#{parse_field_name(field_name)} #{condition}"
      end

      association_field, current_resource = get_association_field_and_resource(field_name)

      # NOTICE: Set the integer value instead of a string if "enum" type
      # NOTICE: Rails 3 do not have a defined_enums method
      if current_resource.respond_to?(:defined_enums) && current_resource.defined_enums.has_key?(association_field)
        value = current_resource.defined_enums[association_field][value]
      end

      parsed_field = parse_field_name(field_name)
      parsed_operator = parse_operator(operator)
      parsed_value = parse_value(operator, value)
      field_and_operator = "#{parsed_field} #{parsed_operator}"

      sanitize_condition(field_and_operator, operator, parsed_value)
    end

    def parse_aggregation_operator(aggregator_operator)
      unless AGGREGATOR_OPERATOR.include?(aggregator_operator)
        raise_unknown_operator_error(aggregator_operator)
      end

      aggregator_operator.upcase
    end

    def parse_operator(operator)
      case operator
      when 'not'
        'NOT'
      when 'greater_than', 'after'
        '>'
      when 'less_than', 'before'
        '<'
      when 'contains', 'starts_with', 'ends_with'
        'LIKE'
      when 'not_contains'
        'NOT LIKE'
      when 'not_equal'
        '!='
      when 'equal'
        '='
      when 'blank'
        'IS'
      when 'present'
        'IS NOT'
      when 'in'
        'IN'
      else
        raise_unknown_operator_error(operator)
      end
    end

    def parse_value(operator, value)
      case operator
      when 'not', 'greater_than', 'less_than', 'not_equal', 'equal', 'before', 'after'
        value
      when 'contains', 'not_contains'
        "%#{value}%"
      when 'starts_with'
        "#{value}%"
      when 'ends_with'
        "%#{value}"
      when 'in'
        if value.kind_of?(String)
          value.split(',').map { |val| val.strip() }
        else
          value
        end
      when 'present', 'blank'
      else
        raise_unknown_operator_error(operator)
      end
    end

    def parse_field_name(field)
      if is_belongs_to(field)
        current_resource = @resource.reflect_on_association(field.split(':').first.to_sym)&.klass
        raise ForestLiana::Errors::HTTP422Error.new("Field '#{field}' not found") unless current_resource

        association = get_association_name_for_condition(field)
        quoted_table_name = ActiveRecord::Base.connection.quote_column_name(association)
        field_name = field.split(':')[1]
      else
        quoted_table_name = @resource.quoted_table_name
        current_resource = @resource
        field_name = field
      end
      quoted_field_name = ActiveRecord::Base.connection.quote_column_name(field_name)

      column_found = current_resource.columns.find { |column| column.name == field.split(':').last }
      if column_found.nil? && !ForestLiana::SchemaHelper.is_smart_field?(current_resource, field_name)
        raise ForestLiana::Errors::HTTP422Error.new("Field '#{field}' not found")
      end

      "#{quoted_table_name}.#{quoted_field_name}"
    end

    def is_belongs_to(field)
      field.include?(':')
    end

    def get_association_name_for_condition(field)
      field, subfield = field.split(':')

      association = @resource.reflect_on_association(field.to_sym)
      return nil if association.blank?

      @joins << association unless @joins.include? association

      association.name
    end

    # NOTICE: Look for a previous interval condition matching the following:
    #         - If the filter is a simple condition at the root the check is done right away.
    #         - There can't be a previous interval condition if the aggregator is 'or' (no meaning).
    #         - The condition's operator has to be elligible for a previous interval.
    #         - There can't be two previous interval condition.
    def get_previous_interval_condition
      current_previous_interval = nil
      # NOTICE: Leaf condition at root
      unless @filters['aggregator']
        return @filters if @operator_date_parser.has_previous_interval?(@filters['operator'])
      end

      if @filters['aggregator'] === 'and'
        @filters['conditions'].each do |condition|
          # NOTICE: Nested conditions
          return nil if condition['aggregator']

          if @operator_date_parser.has_previous_interval?(condition['operator'])
            # NOTICE: There can't be two previous_interval.
            return nil if current_previous_interval

            current_previous_interval = condition
          end
        end
      end

      current_previous_interval
    end

    def apply_filters_on_previous_interval(previous_condition)
      # Ressource should have already been joined
      where = parse_aggregation_on_previous_interval(@filters, previous_condition)

      @resource.where(where)
    end

    def parse_aggregation_on_previous_interval(node, previous_condition)
      raise_empty_condition_in_filter_error unless node

      return parse_previous_interval_condition(node) unless node['aggregator']

      conditions = []
      node['conditions'].each do |condition|
        if condition == previous_condition
          conditions.push(parse_previous_interval_condition(condition))
        else
          conditions.push(parse_aggregation(condition))
        end
      end

      operator = parse_aggregation_operator(node['aggregator'])

      conditions.empty? ? nil : "(#{conditions.join(" #{operator} ")})"
    end

    def parse_previous_interval_condition(condition)
      raise_empty_condition_in_filter_error unless condition

      parsed_condition = @operator_date_parser.get_date_filter_for_previous_interval(
        condition['operator'],
        condition['value']
      )

      "#{parse_field_name(condition['field'])} #{parsed_condition}"
    end

    def raise_unknown_operator_error(operator)
      raise ForestLiana::Errors::HTTP422Error.new("Unknown provided operator '#{operator}'")
    end

    def raise_empty_condition_in_filter_error
      raise ForestLiana::Errors::HTTP422Error.new('Empty condition in filter')
    end

    def ensure_valid_aggregation(node)
      raise ForestLiana::Errors::HTTP422Error.new('Filters cannot be a raw value') unless node.is_a?(Hash)
      raise_empty_condition_in_filter_error if node.empty?
    end

    def ensure_valid_condition(condition)
      raise_empty_condition_in_filter_error if condition.empty?
      raise ForestLiana::Errors::HTTP422Error.new('Condition cannot be a raw value') unless condition.is_a?(Hash)
      unless condition['field'].is_a?(String) and condition['operator'].is_a?(String)
        raise ForestLiana::Errors::HTTP422Error.new('Invalid condition format')
      end
    end

    private

    def prepare_value_for_operator(operator, value)
      # parenthesis around the parsed_value are required to make the `IN` operator work
      operator == 'in' ? "(#{value})" : value
    end

    def sanitize_condition(field_and_operator, operator, parsed_value)
      if Rails::VERSION::MAJOR < 5
        condition_value = prepare_value_for_operator(operator, ActiveRecord::Base.sanitize(parsed_value))
        "#{field_and_operator} #{condition_value}"
        # NOTICE: sanitize method as been removed in Rails 5.1 and sanitize_sql introduced in Rails 5.2.
      elsif Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 1
        condition_value = prepare_value_for_operator(operator, ActiveRecord::Base.connection.quote(parsed_value))
        "#{field_and_operator} #{condition_value}"
      else
        condition_value = prepare_value_for_operator(operator, '?')
        ActiveRecord::Base.sanitize_sql(["#{field_and_operator} #{condition_value}", parsed_value])
      end
    end
  end
end