mrkamel/attr_searchable

View on GitHub
lib/search_cop_grammar/attributes.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require "treetop"

module SearchCopGrammar
  module Attributes
    class Collection
      attr_reader :query_info, :key

      INCLUDED_OPERATORS = [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].freeze

      def initialize(query_info, key)
        raise(SearchCop::UnknownColumn, "Unknown column #{key}") unless query_info.scope.reflection.attributes[key]

        @query_info = query_info
        @key = key
      end

      def eql?(other)
        self == other
      end

      def ==(other)
        other.is_a?(self.class) && [query_info.model, key] == [query_info.model, other.key]
      end

      def hash
        [query_info.model, key].hash
      end

      [:eq, :not_eq, :lt, :lteq, :gt, :gteq].each do |method|
        define_method method do |value|
          attributes.collect! { |attribute| attribute.send method, value }.inject(:or)
        end
      end

      def generator(generator, value)
        attributes.collect! do |attribute|
          SearchCopGrammar::Nodes::Generator.new(attribute, generator: generator, value: value)
        end.inject(:or)
      end

      def matches(value)
        if fulltext?
          SearchCopGrammar::Nodes::MatchesFulltext.new self, value.to_s
        else
          attributes.collect! { |attribute| attribute.matches value }.inject(:or)
        end
      end

      def fulltext?
        (query_info.scope.reflection.options[key] || {})[:type] == :fulltext
      end

      def compatible?(value)
        attributes.all? { |attribute| attribute.compatible? value }
      end

      def options
        query_info.scope.reflection.options[key]
      end

      def attributes
        @attributes ||= query_info.scope.reflection.attributes[key].collect { |attribute_definition| attribute_for attribute_definition }
      end

      def klass_for_association(name)
        reflections = query_info.model.reflections

        return reflections[name].klass if reflections[name]
        return reflections[name.to_sym].klass if reflections[name.to_sym]

        nil
      end

      def klass_for(name)
        alias_value = query_info.scope.reflection.aliases[name]

        return alias_value if alias_value.is_a?(Class)

        value = alias_value || name

        klass_for_association(value) || value.classify.constantize
      end

      def alias_for(name)
        (query_info.scope.reflection.aliases[name] && name) || klass_for(name).table_name
      end

      def attribute_for(attribute_definition)
        query_info.references.push attribute_definition

        table, column_with_fields = attribute_definition.split(".")
        column, *fields = column_with_fields.split("->")
        klass = klass_for(table)

        raise(SearchCop::UnknownAttribute, "Unknown attribute #{attribute_definition}") unless klass.columns_hash[column]

        Attributes.const_get(klass.columns_hash[column].type.to_s.classify).new(klass, alias_for(table), column, fields, options)
      end

      def generator_for(name)
        generators[name]
      end

      def valid_operator?(operator)
        (INCLUDED_OPERATORS + generators.keys).include?(operator)
      end

      def generators
        query_info.scope.reflection.generators
      end
    end

    class Base
      attr_reader :attribute, :table_alias, :column_name, :field_names, :options

      def initialize(klass, table_alias, column_name, field_names, options = {})
        @attribute = klass.arel_table.alias(table_alias)[column_name]
        @klass = klass
        @table_alias = table_alias
        @column_name = column_name
        @field_names = field_names
        @options = (options || {})
      end

      def map(value)
        value
      end

      def compatible?(value)
        map value

        true
      rescue SearchCop::IncompatibleDatatype
        false
      end

      def fulltext?
        false
      end

      { eq: "Equality", not_eq: "NotEqual", lt: "LessThan", lteq: "LessThanOrEqual", gt: "GreaterThan", gteq: "GreaterThanOrEqual", matches: "Matches" }.each do |method, class_name|
        define_method method do |value|
          raise(SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}") unless compatible?(value)

          SearchCopGrammar::Nodes.const_get(class_name).new(self, map(value))
        end
      end

      def method_missing(name, *args, &block)
        if @attribute.respond_to?(name)
          @attribute.send(name, *args, &block)
        else
          super
        end
      end

      def respond_to_missing?(*args)
        @attribute.respond_to?(*args) || super
      end
    end

    class String < Base
      def matches_value(value)
        res = value.gsub(/[%_\\]/) { |char| "\\#{char}" }

        if value.strip =~ /^\*|\*$/
          res = res.gsub(/^\*/, "%") if options[:left_wildcard] != false
          res = res.gsub(/\*$/, "%") if options[:right_wildcard] != false

          return res
        end

        res = "%#{res}" if options[:left_wildcard] != false
        res = "#{res}%" if options[:right_wildcard] != false
        res
      end

      def matches(value)
        super matches_value(value)
      end
    end

    class Text < String; end
    class Jsonb < String; end
    class Json < String; end
    class Hstore < String; end

    class WithoutMatches < Base
      def matches(value)
        eq value
      end
    end

    class Float < WithoutMatches
      def compatible?(value)
        return true if value.to_s =~ /^-?[0-9]+(\.[0-9]+)?$/

        false
      end

      def map(value)
        value.to_f
      end
    end

    class Integer < Float
      def map(value)
        value.to_i
      end
    end

    class Decimal < Float; end

    class Datetime < WithoutMatches
      def parse(value)
        return value..value unless value.is_a?(::String)

        if value =~ /^[0-9]+ (hour|day|week|month|year)s{0,1} (ago)$/
          number, period, ago = value.split(" ")
          time = number.to_i.send(period.to_sym).send(ago.to_sym)
          time..::Time.now
        elsif value =~ /^[0-9]{4}$/
          ::Time.new(value).beginning_of_year..::Time.new(value).end_of_year
        elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$}
          ::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).beginning_of_month..::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).end_of_month
        elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$}
          ::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).beginning_of_month..::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).end_of_month
        elsif value =~ %r{^[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}$} || value =~ %r{^[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}$}
          time = ::Time.parse(value)
          time.beginning_of_day..time.end_of_day
        elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}}
          time = ::Time.parse(value)
          time..time
        else
          raise ArgumentError
        end
      rescue ArgumentError
        raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}"
      end

      def map(value)
        parse(value).first
      end

      def eq(value)
        between parse(value)
      end

      def not_eq(value)
        between(parse(value)).not
      end

      def gt(value)
        super parse(value).last
      end

      def between(range)
        gteq(range.first).and(lteq(range.last))
      end
    end

    class Timestamp < Datetime; end
    class Timestamptz < Datetime; end

    class Date < Datetime
      def parse(value)
        return value..value unless value.is_a?(::String)

        if value =~ /^[0-9]+ (day|week|month|year)s{0,1} (ago)$/
          number, period, ago = value.split(" ")
          time = number.to_i.send(period.to_sym).send(ago.to_sym)
          time.to_date..::Date.today
        elsif value =~ /^[0-9]{4}$/
          ::Date.new(value.to_i).beginning_of_year..::Date.new(value.to_i).end_of_year
        elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$}
          ::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).end_of_month
        elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$}
          ::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).end_of_month
        elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}}
          date = ::Date.parse(value)
          date..date
        else
          raise ArgumentError
        end
      rescue ArgumentError
        raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}"
      end
    end

    class Time < Datetime; end

    class Boolean < WithoutMatches
      def map(value)
        return true if value.to_s =~ /^(1|true|yes)$/i
        return false if value.to_s =~ /^(0|false|no)$/i

        raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}"
      end
    end
  end
end