Skip to content

Instantly share code, notes, and snippets.

@IceDragon200
Forked from robhurring/search_terms.rb
Last active March 21, 2016 18:17
Show Gist options
  • Save IceDragon200/004e73bc57598d6254ec to your computer and use it in GitHub Desktop.
Save IceDragon200/004e73bc57598d6254ec to your computer and use it in GitHub Desktop.
# Search term parser from https://gist.github.com/1477730
# Modified to allow periods (and other non-letter chars) in unquoted field values
# and field names.
#
# Helper class to help parse out more advanced saerch terms
# from a form query
#
# Note: all hash keys are downcased, so ID:10 == {'id' => 10}
# you can also access all keys with methods e.g.: terms.id = terms['id'] = 10
# this doesn't work with query as thats reserved for the left-over pieces
#
# Usage:
# terms = SearchTerms.new('id:10 search terms here')
# => @query="search terms here", @parts={"id"=>"10"}
# => terms.query = 'search terms here'
# => terms['id'] = 10
#
# terms = SearchTerms.new('name:"support for spaces" state:pa')
# => @query="", @parts={"name"=>"support for spaces", "state"=>"pa"}
# => terms.query = ''
# => terms['name'] = 'support for spaces'
# => terms.name = 'support for spaces'
#
# terms = SearchTerms.new('state:pa,nj,ca')
# => @query="", @parts={"state"=>["pa","nj","ca"]}
#
# terms = SearchTerms.new('state:pa,nj,ca', false)
# => @query="", @parts={"state"=>"pa,nj,c"}
#
# Useful to drive custom logic in controllers
class SearchTerms
module Parser
# regex scanner for the parser
SCANNER = %r{
(?:
([\w\.]+) # look for any word
)
(?: # check if it has a value attached
: # find the value delimiter
(
[\w,\-]+ # match any word-like values
| # -or-
(?:"(?:.+|[^\"])*") # match any quoted values
)
)?
}x
class << self
private def clean_value(value, **options)
return value.tr('"', '') if value.include?('"')
return value.split(',') if options.fetch(:split, true) && value.include?(',')
return true if value == 'true'
return false if value == 'false'
return value.to_i if value =~ /^[1-9][0-9]*$/
value
end
def parse(query, **options)
keywords = []
queries = []
parts = {}
query.scan(SCANNER).map do |key, value|
if value.nil?
keywords << key
queries << [:keyword, key]
else
k = key.downcase
v = clean_value(value, options)
parts[k] = v
queries << [:key_value, { key: k, value: v }]
end
end
{
original_query: query,
query: keywords.join(' '),
keywords: keywords,
queries: queries,
parts: parts
}
end
end
end
# @return [String] Original query string passed in
attr_reader :original_query
# @return [Array<Array>] In case you need the actual order of query parameters
# The sub arrays are of the following structure:
# [Symbol, Object]
# Where Symbol is the kind of query parameter, and Object is the value
# Symbol can be:
# :keyword - the value is a String keyword
# :key_value - the value is a `key` `value` pair as a Hash with keys of the same name
attr_reader :queries
# @return [String] the reconstructed query string
attr_reader :query
# @return [Array<String>] all keywords, things that didn't make into the parts
attr_reader :keywords
# @return [Hash<String, Object>] the parsed parameters
attr_reader :parts
# @param [Hash<Symbol, Object>] options
# @option [Array<Array>] :queries
# @option [Array<String>] :keywords
# @option [String] :original_query
# @option [String] :query
# @option [Hash<String, Object>] :parts
def initialize(**options)
@queries = options.fetch(:queries, [])
@keywords = options.fetch(:keywords, [])
@original_query = options.fetch(:original_query, '')
@query = options.fetch(:query, @original_query)
@parts = options.fetch(:parts)
end
# @param [String, Symbol] key
# @return [Object] value
def [](key)
@parts[key.to_s]
end
# Slices the parts into a new Hash
#
# @param [Array<String, Symbol>] keys
# @return [SearchTerms] sliced search terms
def slice(*keys)
SearchTerms.new(
queries: @queries,
keywords: @keywords,
original_query: @original_query,
query: @query,
parts: @parts.slice(*keys.map(&:to_s))
)
end
# @return [Hash<String, Object>] hash
def to_hash
@parts.to_hash
end
# @overload each_query
# @return [Enumerator]
# @overload each_query { |type, value| }
# @yieldparam [Symbol] type
# @yieldparam [Object] value
def each_query
return to_enum :each_query unless block_given?
@queries.each do |row|
yield(*row)
end
end
# @param [String] query
# @param [Hash<Symbol, Object>] options
# @option [Boolean] :split should array like values be split?
def self.parse(query, **options)
new SearchTerms::Parser.parse(query, options)
end
end
require 'spec_helper'
describe SearchTerms do
# A rather crude spec but hey it works
context '.parse' do
it 'should parse a query' do
q = 'to:Eggman from:Sonic meta:1,2,3,4 IAmAKeyword'
st = described_class.parse(q)
expected_parts = { 'to' => 'Eggman', 'from' => 'Sonic', 'meta' => %w[1 2 3 4] }
expect(st).to be_instance_of described_class
expect(st.original_query).to eq(q)
expect(st.keywords).to eq(['IAmAKeyword'])
expect(st.parts).to eq(expected_parts)
expect(st.to_hash).to eq(expected_parts)
expect(st['to']).to eq('Eggman')
expect(st[:meta]).to eq(%w[1 2 3 4])
expect(st.each_query).to be_instance_of(Enumerator)
expect(st.each_query.to_a).to eq([
[:key_value, { key: 'to', value: 'Eggman' }],
[:key_value, { key: 'from', value: 'Sonic' }],
[:key_value, { key: 'meta', value: %w[1 2 3 4] }],
[:keyword, 'IAmAKeyword']
])
st2 = st.slice('from', :to)
expect(st2.parts).to eq({ 'to' => 'Eggman', 'from' => 'Sonic' })
end
end
end
# basic usage to search users from your #index action
class UsersController < ApplicationController
def index
if params[:q]
terms = SearchTerms.parse(params[:q])
if terms['id']
return redirect_to user_path(terms['id'])
else
@users = @users.search_by_name(terms.query) unless terms.query.blank?
@users = @users.with_role(terms['role']) if terms['role']
@users = @users.registered(false) if terms['guest']
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment