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