# frozen-string-literal: true require 'search_lingo/tokenizer' module SearchLingo ## # AbstractSearch is an abstract implementation from which search classes # should inherit. # # Search classes are instantiated with a query string and a default scope on # which to perform the search. # # Child classes must implement the #default_parse instance method, and they # may optionally register one or more parsers. # # class MySearch < SearchLingo::AbstractSearch # def default_parse(token, chain) # chain.where attribute: token.term # end # end # # class MyOtherSearch < SearchLingo::AbstractSearch # parser SearchLingo::Parsers::DateParser.new Job.arel_table[:date] # # parser do |token, chain| # token.match(/\Aid: [[:space:]]* (?[[:digit:]]+)\z/x) do |m| # chain.where id: m[:id] # end # end # # def default_parse(token, chain) # chain.where Job.arel_table[:name].matches "%#{token.term}%" # end # end class AbstractSearch attr_reader :query, :scope ## # Instantiates a new search object. +query+ is the string that is to be # parsed and compiled into an actual query. If +query+ is falsey, an empty # string will be used. +scope+ is the object to which the compiled query # should be sent, e.g., an +ActiveRecord::Relation+. # # MySearchClass.new 'foo bar: baz "froz quux"', Task.all def initialize(query, scope) @query = query || '' @scope = scope end ## # Returns an list of parsers that have been added to this class. def self.parsers @parsers ||= [] end ## # Adds a new parser to the list of parsers used by this class. # # The parser may be given as an anonymous block or as any argument which # responds to +#call+. The parser will be send +#call+ with a single # argument which will be a token from the query string. # # If both a callable object and a block are given, or if neither a callable # object nor a block are given, an +ArgumentError+ will be raised. # # class MyParser # def call(token) # # return something # end # end # # class MySearch < SearchLingo::AbstractSearch # parser MyParser.new # parser do |token| # # return something # end # end def self.parser(parser = nil, &block) unless block_given? ^ parser.respond_to?(:call) raise ArgumentError, 'parse must be called with callable OR block' end parsers << (parser || block) end ## # Delegates to SearchLingo::AbstractSearch.parsers. def parsers self.class.parsers end ## # Returns the results of executing the search. def results @results ||= load_results end ## # Load search results by composing query string tokens into a query chain. # # @query is borken down into tokens, and each token is passed through the # list of defined parsers. If a parser is successful, +:match+ is thrown, # processing moves on to the next token. If none of the parsers succeed and # the token is compound, the token is simplified and reprocessed as before. # If still no parser succeeds, fall back on +#default_parse+. def load_results tokenizer.reduce(scope) do |chain, token| catch(:match) do # 1. Try each parser with token until :match is thrown. parse token, chain # 2. If :match not thrown and token is compund, simplify and retry. if token.compound? token = tokenizer.simplify parse token, chain end # 3. If :match still not thrown, fall back on default parser. default_parse token, chain end end end ## # Returns a +SearchLingo::Tokenizer+ for @query. def tokenizer @tokenizer ||= Tokenizer.new query end ## # Passes +token+ to each parser in turn. If a parser succeeds, throws # +:match+ with the result. # # A parser succeeds if +call+ returns a truthy value. A successful parser # will typically send something to +chain+ and return the result. In this # way, the tokens of the search are reduced into a composed query. def parse(token, chain) parsers.each do |parser| result = parser.call token, chain throw :match, result if result end nil end ## # The default way to handle a token which could not be parsed by any of the # other parsers. # # This is a skeletal implementation that raises +NotImplementedError+. # Child classes should provide their own implementation. At a minimum, that # implementation should return +chain+. (Doing so would ignore +token+.) def default_parse(_token, _chain) raise NotImplementedError, "#default_parse must be implemented by #{self.class}" end end end