module Groovy class Query include Enumerable AND = '+'.freeze NOT = '-'.freeze PER_PAGE = 50.freeze VALID_QUERY_CHARS = 'a-zA-Z0-9_\.,&-'.freeze REMOVE_INVALID_CHARS_REGEX = Regexp.new('[^\s' + VALID_QUERY_CHARS + ']').freeze attr_reader :parameters, :sorting def self.add_scope(name, obj) define_method(name) do |*args| res = obj.respond_to?(:call) ? instance_exec(*args, &obj) : obj self end end def initialize(model, table, options = {}) @model, @table, @options = model, table, options @parameters = options.delete(:parameters) || [] @sorting = { limit: -1, offset: 0 } @default_sort_key = table.is_a?(Groonga::Hash) ? '_key' : '_id' end # def inspect # "<#{self.class.name} #{parameters}>" # end def as_json(options = {}) Array.new.tap do |arr| each { |record| arr.push(record.as_json(options)) } end end def search(obj) obj.each do |col, q| # unless model.schema.index_columns.include?(col) # raise "Not an index column, so cannot do fulltext search: #{col}" # end q.split(' ').each do |word| parameters.push(AND + "(#{col}:@#{word})") end if q.is_a?(String) && q.strip != '' end self end def find(id) find_by(_id: id) end def find_by(conditions) where(conditions).limit(1).first end def find_each(opts = {}, &block) count = 0 in_batches({ of: 10 }.merge(opts)) do |group| group.each { |item| count += 1; yield(item) } end count end # http://groonga.org/docs/reference/grn_expr/query_syntax.html # TODO: support match_columns (search value in two or more columns) def where(conditions = nil) case conditions when String # "foo:bar" add_param(AND + "(#{map_operator(conditions)})") when Hash # { foo: 'bar' } or { views: 1..100 } conditions.each do |key, val| if val.is_a?(Range) add_param(AND + [key, val.min].join(':>=')) if val.min # lte add_param(AND + [key, val.max].join(':<=')) if val.max # gte elsif val.is_a?(Regexp) str = val.source.gsub(REMOVE_INVALID_CHARS_REGEX, '') param = val.source[0] == '^' ? ':^' : val.source[-1] == '$' ? ':$' : ':~' # starts with or regexp add_param(AND + [key, str.downcase].join(param)) # regex must be downcase elsif val.is_a?(Array) # { foo: [1,2,3] } str = "#{key}:#{val.join(" OR #{key}:")}" add_param(AND + str) else str = val.nil? || val == false || val.to_s.strip == '' ? '\\' : escape_val(val) add_param(AND + [key, str].join(':')) end end # when Array # ["foo:?", val] # parameters.push(conditions.first.sub('?', conditions.last)) when NilClass # doing where.not probably else raise 'not supported' end self end def not(conditions = {}) case conditions when String # "foo:bar" parameters.push(NOT + "(#{map_operator(conditions)})") when Hash # { foo: 'bar' } conditions.each do |key, val| if val.is_a?(Range) add_param(AND + [key, val.min].join(':<=')) if val.min > 0 # gte add_param(AND + [key, val.max].join(':>=')) if val.max # lte, nil if range.max is -1 elsif val.is_a?(Regexp) str = val.source.gsub(REMOVE_INVALID_CHARS_REGEX, '') param = val.source[0] == '^' ? ':^' : val.source[-1] == '$' ? ':$' : ':~' # starts with or regexp add_param(NOT + [key, str.downcase].join(param)) # regex must be downcase elsif val.is_a?(Array) # { foo: [1,2,3] } str = "#{key}:!#{val.join(" #{AND}#{key}:!")}" add_param(AND + str) else str = val.nil? || val == false || val.to_s.strip == '' ? '\\' : escape_val(val) add_param(AND + [key, str].join(':!')) # not end end # when Array # ["foo:?", val] # parameters.push(conditions.first.sub('?', conditions.last)) else raise 'not supported' end self end def limit(num) @sorting[:limit] = num self end def offset(num) @sorting[:offset] = num self end def paginate(page = 1) page = 1 if page.to_i < 1 offset = ((page.to_i)-1) * PER_PAGE offset(offset).limit(PER_PAGE) # returns self end # sort_by(title: :asc) def sort_by(hash) if hash.is_a?(String) || hash.is_a?(Symbol) # e.g. 'title.desc' or :title (asc by default) param, dir = hash.to_s.split('.') hash = {} hash[param] = dir || 'asc' end sorting[:by] = hash.keys.map do |key| { key: key.to_s, order: hash[key] } end self end def group_by(column) sorting[:group_by] = column self end def query self end def all @records || query end def size results.size end alias_method :count, :size def to_a records end def [](index) records[index] end def each(&block) records.each { |r| block.call(r) } end def update_all(attrs) each { |r| r.update_attributes(attrs) } end def total_entries results # ensure query has been run @total_entries end def last(count = 1) if count > 1 records[(size-count)..-1] else records[size-1] end end def in_batches(of: 1000, from: nil, &block) @sorting[:limit] = of @sorting[:offset] = from || 0 while results.any? yield to_a break if results.size < of @sorting[:offset] += of @records = @results = nil # reset end end def records @records ||= results.map do |r| model.new_from_record(r) end end private attr_reader :model, :table, :options def add_param(param) if parameters.include?(param) raise "Duplicate param: #{param}" end # if param matches blank/nil, put at the end of the stack param[/:\!?\\/] ? parameters.push(param) : parameters.unshift(param) end def results @results ||= execute rescue Groonga::TooLargeOffset puts "Offset is higher than table size!" [] end def execute set = if parameters.any? query = prepare_query debug "Finding records with query: #{query}" table.select(query, options) else debug "Finding records with options: #{options.inspect}" table.select(options) end @total_entries = set.size debug "Sorting with #{sort_key_and_order}, #{sorting.inspect}" set = set.sort(sort_key_and_order, { limit: sorting[:limit], offset: sorting[:offset], # [sorting[:offset], @total_entries].min }) sorting[:group_by] ? set.group(group_by) : set end def map_operator(str) str.sub(' = ', ':') .sub(' != ', ':!') .sub(' ~ ', ':~') .sub(' < ', ':<') .sub(' > ', ':>') .sub(' <= ', ':<=') .sub(' >= ', ':>=') end def sort_key_and_order sorting[:by] or [{ key: @default_sort_key, order: :asc }] end def escape_val(val) val.to_s.gsub(':', '\:') end def prepare_query space_regex = Regexp.new('\s([' + VALID_QUERY_CHARS + '])') query = parameters.join(' ').split(/ or /i).map do |part| part.gsub(' ', ' ') # replace double with single spaces .gsub(space_regex, '\ \1') # escape spaces before word letters .gsub(/(\d\d):(\d\d):(\d\d)/, '\1\:\2\:\3') # escape hh:mm:ss in timestamps end.join(' OR ').sub(/^-/, '_id:>0 -') #.gsub(' OR -', ' -') end def debug(str) puts str if ENV['DEBUG'] end end end