Rails Search ActiveRecord with Logical Operators - ruby-on-rails

I'm wondering what the best way to parse a text query in Rails is, to allow the user to include logical operators?
I'd like the user to be able to enter either of these, or some equivalent:
# searching partial text in emails, just for example
# query A
"jon AND gmail" #=> ["jonsmith#gmail.com"]
# query B
"jon OR gmail" #=> ["jonsmith#gmail.com", "sarahcalaway#gmail.com"]
# query C
"jon AND gmail AND smith" #=> ["jonsmith#gmail.com"]
Ideally, we could get even more complex with parentheses to indicate order of operations, but that's not a requirement.
Is there a gem or a pattern that supports this?

This is a possible but inefficient way to do this:
user_input = "jon myers AND gmail AND smith OR goldberg OR MOORE"
terms = user_input.split(/(.+?)((?: and | or ))/i).reject(&:empty?)
# => ["jon myers", " AND ", "gmail", " AND ", "smith", " OR ", "goldberg", " OR ", "MOORE"]
pairs = terms.each_slice(2).map { |text, op| ["column LIKE ? #{op} ", "%#{text}%"] }
# => [["column LIKE ? AND ", "%jon myers%"], ["column LIKE ? AND ", "%gmail%"], ["column LIKE ? OR ", "%smith%"], ["column LIKE ? OR ", "%goldberg%"], ["column LIKE ? ", "%MOORE%"]]
query = pairs.reduce([""]) { |acc, terms| acc[0] += terms[0]; acc << terms[1] }
# => ["column LIKE ? AND column LIKE ? AND column LIKE ? OR column LIKE ? OR column LIKE ? ", "%jon myers%", "%gmail%", "%smith%", "%goldberg%", "%MOORE%"]
Model.where(query[0], *query[1..-1]).to_sql
# => SELECT "courses".* FROM "courses" WHERE (column LIKE '%jon myers%' AND column LIKE '%gmail%' AND column LIKE '%smith%' OR column LIKE '%goldberg%' OR column LIKE '%MOORE%' )
However, as I said, searches like this one are extremely inefficient. I'd recommend you use a full-text search engine, like Elasticsearch.

I use such a parser in a Sinatra app, since the queries tend to be complex I produce plain SQL instead of using the activerecords selection methods.
If you can use it, feel free..
You use it like this, class_name is the activerecord class representing the table, params is a hash of strings to parse, the result is sent to the browser as Json
eg
generic_data_getter (Person, {age: ">30",name: "=John", date: ">=1/1/2014 <1/1/2015"})
def generic_data_getter (class_name, params, start=0, limit=300, sort='id', dir='ASC')
selection = build_selection(class_name, params)
data = class_name.where(selection).offset(start).limit(limit).order("#{sort} #{dir}")
{:success => true, :totalCount => data.except(:offset, :limit, :order).count, :result => data.as_json}
end
def build_selection class_name, params
field_names = class_name.column_names
selection = []
params.each do |k,v|
if field_names.include? k
type_of_field = class_name.columns_hash[k].type.to_s
case
when (['leeg','empty','nil','null'].include? v.downcase) then selection << "#{k} is null"
when (['niet leeg','not empty','!nil','not null'].include? v.downcase) then selection << "#{k} is not null"
when type_of_field == 'string' then
selection << string_selector(k, v)
when type_of_field == 'integer' then
selection << integer_selector(k, v)
when type_of_field == 'date' then
selection << date_selector(k, v)
end
end
end
selection.join(' and ')
end
def string_selector(k, v)
case
when v[/\|/]
v.scan(/([^\|]+)(\|)([^\|]+)/).map {|p| "lower(#{k}) LIKE '%#{p.first.downcase}%' or lower(#{k}) LIKE '%#{p.last.downcase}%'"}
when v[/[<>=]/]
v.scan(/(<=?|>=?|=)([^<>=]+)/).map { |part| "#{k} #{part.first} '#{part.last.strip}'"}
else
"lower(#{k}) LIKE '%#{v.downcase}%'"
end
end
def integer_selector(k, v)
case
when v[/\||,/]
v.scan(/([^\|]+)([\|,])([^\|]+)/).map {|p|p p; "#{k} IN (#{p.first}, #{p.last})"}
when v[/\-/]
v.scan(/([^-]+)([\-])([^-]+)/).map {|p|p p; "#{k} BETWEEN #{p.first} and #{p.last}"}
when v[/[<>=]/]
v.scan(/(<=?|>=?|=)([^<>=]+)/).map { |part| p part; "#{k} #{part.first} #{part.last}"}
else
"#{k} = #{v}"
end
end
def date_selector(k, v)
eurodate = /^(\d{1,2})[-\/](\d{1,2})[-\/](\d{1,4})$/
case
when v[/\|/]
v.scan(/([^\|]+)([\|])([^\|]+)/).map {|p|p p; "#{k} IN (DATE('#{p.first.gsub(eurodate,'\3-\2-\1')}'), DATE('#{p.last.gsub(eurodate,'\3-\2-\1')}'))"}
when v[/\-/]
v.scan(/([^-]+)([\-])([^-]+)/).map {|p|p p; "#{k} BETWEEN DATE('#{p.first.gsub(eurodate,'\3-\2-\1')}')' and DATE('#{p.last.gsub(eurodate,'\3-\2-\1')}')"}
when v[/<|>|=/]
parts = v.scan(/(<=?|>=?|=)(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/)
selection = parts.map do |part|
operator = part.first ||= "="
date = Date.parse(part.last.gsub(eurodate,'\3-\2-\1'))
"#{k} #{operator} DATE('#{date}')"
end
when v[/^(\d{1,2})[-\/](\d{1,4})$/]
"#{k} >= DATE('#{$2}-#{$1}-01') and #{k} <= DATE('#{$2}-#{$1}-31')"
else
date = Date.parse(v.gsub(eurodate,'\3-\2-\1'))
"#{k} = DATE('#{date}')"
end
end

The simplest case would be extract an array from the strings:
and_array = "jon AND gmail".split("AND").map{|e| e.strip}
# ["jon", "gmail"]
or_array = "jon OR sarah".split("OR").map{|e| e.strip}
# ["jon", "sarah"]
Then you could construct an query string:
query_string = ""
and_array.each {|e| query_string += "%e%"}
# "%jon%%gmail%"
Then you use a ilike or a like query to fetch the results:
Model.where("column ILIKE ?", query_string)
# SELECT * FROM model WHERE column ILIKE '%jon%%gmail%'
# Results: jonsmith#gmail.com
Of course that could be a little overkill. But it is a simple solution.

Related

Dynamically create query - Rails 5

If I manually write a query, it will be like
User.where("name LIKE(?) OR desc LIKE(?)",'abc','abc')
.where("name LIKE(?) OR desc LIKE(?)",'123','123')
However, I need to dynamically generate that query.
I am getting data like
def generate_query(the_query)
query,keywords = the_query
# Here
# query = "name LIKE(?) OR desc LIKE(?)"
# keywords = [['abc','abc'],['123','123']]
keywords.each do |keyword|
users = User.where(query,*keyword) <-- not sure how to dynamically add more 'where' conditions.
end
end
I am using Rails 5. Hope it is clear. Any help appreciated :)
Something like this:
q = User.where(a)
.where(b)
.where(c)
is equivalent to:
q = User
q = q.where(a)
q = q.where(b)
q = q.where(c)
So you could write:
users = User
keywords.each do |keyword|
users = users.where(query, *keyword)
end
But any time you see that sort of feedback pattern (i.e. apply an operation to the operation's result or f(f( ... f(x)))) you should start thinking about Enumerable#inject (AKA Enumerable#reduce):
users = keywords.inject(User) { |users, k| users.where(query, *k) }
That said, your query has two placeholders but keywords is just a flat array so you won't have enough values in:
users.where(query, *k)
to replace the placeholders. I think you'd be better off using a named placeholder here:
query = 'name like :k or desc like :k'
keywords = %w[abc 123]
users = keywords.inject(User) { |users, k| users.where(query, k: k) }
You'd probably also want to include some pattern matching for your LIKE so:
query = "name like '%' || :k || '%' or desc like '%' || :k || '%'"
users = keywords.inject(User) { |users, k| users.where(query, k: k)
where || is the standard SQL string concatenation operator (which AFAIK not all databases understand) and % in a LIKE pattern matches any sequence of characters. Or you could add the pattern matching in Ruby and avoid having to worry about the different ways that databases handle string concatenation:
query = 'name like :k or desc like :k'
users = keywords.inject(User) { |users, k| users.where(query, k: "%#{k}%")
Furthermore, this:
User.where("name LIKE(?) OR desc LIKE(?)",'abc','abc')
.where("name LIKE(?) OR desc LIKE(?)",'123','123')
produces a WHERE clause like:
where (name like 'abc' or desc like 'abc')
and (name like '123' or desc like '123')
so you're matching all the keywords, not any of them. This may or may not be your intent.

Searching value range rails

How can I add another search value in the scope and add it into the terms.map
and pass it on to the where query, I want to have min and a max value?
scope :search_query, lambda { |query|
return nil if query.blank?
# condition query, parse into individual keywords
terms = query.downcase.split(/\s+/)
# replace "*" with "%" for wildcard searches,
# append '%', remove duplicate '%'s
terms = terms.map { |e|
(e.gsub('*', '%') + '%').gsub(/%+/, '%')
}
num_or_conds = 2
where(
terms.map { |term|
"(LOWER(students.first_name) LIKE ? OR LOWER(students.last_name) LIKE ?)"
}.join(' AND '),
*terms.map { |e| [e] * num_or_conds }.flatten
)
}
What i would like to do
.where(column_name BETWEEN #{value1} AND #{value2})
You can chain scopes in rails by just calling where repeatedly:
Thing.where(a: 1).where(b: 2)
# SELECT things.* FROM things WHERE things.a = ? AND things.b = ?
You can also use .merge to merge scopes:
Thing.where(a: 1).merge(Thing.where(b: 2))
Use a range create a BETWEEN query:
Thing.where(foo: (1..10))
# SELECT things.* FROM things WHERE foo BETWEEN 1 AND 10
This also works for dates and times.
Another thing to bear in mind in that scope is just syntactic sugar for class methods. So if your method does not fit in a one-liner you should use the the "classical" method definition:
class Student < ApplicationRecord
def self.search_query(query)
scope = self.all
terms = query.downcase.split(/\s+/)
terms = terms.map { |e|
(e.gsub('*', '%') + '%').gsub(/%+/, '%')
}
self.all.tap do |scope|
terms.each do |term|
scope.merge(
self.where("(LOWER(students.first_name) LIKE :t OR LOWER(students.last_name) LIKE :t)", t: term)
)
end
end
end
end

Better search query for two columns forename and name

Actually i have this search query from MrJoshi here is the associated question:
Search query for (name or forename) and (name forname) and (forname name)
def self.search(query)
return where('FALSE') if query.blank?
conditions = []
search_columns = [ :forname, :name ]
query.split(' ').each do |word|
search_columns.each do |column|
conditions << " lower(#{column}) LIKE lower(#{sanitize("%#{word}%")}) "
end
end
conditions = conditions.join('OR')
self.where(conditions)
end
The problem with this search query is that it returns way to much records. For example if somebody is searching for John Smith this search query returns all records wih the forename John and all records with the name Smith although there is only one person that exactly matches the search query means name is Smith and forename is John
So i changed the code a little bit:
def self.search(query)
return where('FALSE') if query.blank?
conditions = []
query2 = query.split(' ')
if query2.length == 2
conditions << " lower(:forname) AND lower(:name) LIKE ?', lower(#{sanitize("%#{query2.first}%")}) , lower(#{sanitize("%#{query2.last}%")})"
conditions << " lower(:forname) AND lower(:name) LIKE ?', lower(#{sanitize("%#{query2.last}%")}) , lower(#{sanitize("%#{query2.first}%")})"
else
search_columns = [ :forname, :name ]
query2.each do |word|
search_columns.each do |column|
conditions << " lower(#{column}) LIKE lower(#{sanitize("%#{word}%")}) "
end
end
end
conditions = conditions.join('OR')
self.where(conditions)
end
But now i get this error:
SQLite3::SQLException: near "', lower('": syntax error: SELECT "patients".* FROM "patients" WHERE ( lower(:forname) AND lower(:name) LIKE ?', lower('%John%') , lower('%Smith%')OR lower(:forname) AND lower(:name) LIKE ?', lower('%Smith%') , lower('%John%')) LIMIT 12 OFFSET 0
What did i wrong? Thanks!

create conditions in model

I trying to create the conditions of my search, but I have some troubles.
This is the method I'm trying to create.
def self.searchadv(title, place, category, date)
!title.blank? ? conditions = ['title LIKE ?', "%#{title}%"] : conditions = []
if conditions
!place.blank? ? conditions << [' AND place LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' AND category LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' AND date LIKE ?', "%#{place}%"] : conditions << []
else
!place.blank? ? conditions << [' place LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' category LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' date LIKE ?', "%#{place}%"] : conditions << []
end
find(:all, :conditions => conditions)
end
It works, greate until I try to append the place parameter and I get this error
wrong number of bind variables (4 for 1) in: title LIKE ?
if I delete this:
if conditions
!place.blank? ? conditions << [' AND place LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' AND category LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' AND date LIKE ?', "%#{place}%"] : conditions << []
else
!place.blank? ? conditions << [' place LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' category LIKE ?', "%#{place}%"] : conditions << []
!place.blank? ? conditions << [' date LIKE ?', "%#{place}%"] : conditions << []
end
Everything works great, but I need this other options in order to create my search and I don't undertand why the error is in the "LiKE"
Does anyone could help me please?
Thanks in advance!
That is exceptionally ugly, hard to read/debug, and thus prone to error
def self.searchadv(title, place, category, date)
conditions = {}
conditions[:title] = "%#{title}%" if title.present?
if place.present?
conditions[:place] = "%#{place}%"
conditions[:category] = "%#{category}%"
conditions[:date] = "%#{date}%"
end
where(conditions)
end
Edit As OP pointed out, the above doesn't allow for wildcard matches. The following uses ARel's matches method to accomplish this.
def self.searchadv(title, place, category, date)
offers = self.arel_table
predicates = []
predicates << offers[:title].matches("%#{title}%") if title.present?
if place.present?
predicates << offers[:place].matches("%#{place}%")
predicates << offers[:category].matches("%#{category}%")
predicates << offers[:date].matches("%#{date}%")
end
if predicates.size > 1
first = predicates.shift
conditions = Arel::Nodes::Grouping.new(predicates.inject(first) {|memo, expr| Arel::Nodes::And.new(memo, expr)})
else
conditions = predicates.first
end
where(conditions).to_a
end
I was first trying to keep your code and only try to correct it, but there were to much bad things :) Here is how I would have done it keeping the same structure as you are using (BUT perhaps it's better to use a conditions Hash as in #Deefour's suggestion):
def self.searchadv(title, place, category, date)
cond_ary = []
cond_values = []
unless title.blank?
cond_ary << 'title LIKE ?'
cond_values << "%#{title}%"
end
unless place.blank?
cond_ary << 'place LIKE ? AND category LIKE ? AND date LIKE ?'
cond_values.push("%#{place}%", "%#{place}%", "%#{place}%")
end
conditions = [ cond_ary.join(' AND ') ] + cond_values
find(:all, :conditions => conditions)
end
May I suggest that you study how a conditions array should look like, and then in a ruby console play around with an Array. E.g. see what happens with the array with e.g. ary << 1, ary << [1,2,3], ary.concat([1,2,3]), ary.push(1,2,3) etc.
And when you do
expr ? x = 1 : x = 2
It's better to use
x = expr ? 1 : 2

Get multiple records with one query

User table:
name lastname
Bob Presley
Jamie Cox
Lucy Bush
Roman Cox
Find users
q = Query.new("Bob Presley, Cox, Lucy")
q.find_users => {0=>{:name=>"Bob", :lastname=>"Presley"}, 1=>{:lastname=>"Cox"}, 2=>{:name=>"Lucy"}}
Question:
I've got hash with few names and lastnames. I need to build Activerecord query to fetch all users from that hash.
If i have name and lastname I should find user with exactly the same name and lastname.
If I have only lastname or name I should find all users with this name or lastname. So when i search for :lastname => Cox it should return two users [Roman Cox,Jamie Cox]
I can do
object = []
hash = q.find_users
hash.each do |data|
#Pseudocode
# object << User.where(:name => if data[:lastname] exist, :lastname => if data[:name] exist)
end
But I think it is higly inefficient. How should I do this ?
Environment
rails: 3.0.3
ruby: 1.9.2-head
gem: meta_search https://github.com/ernie/meta_search
I'm sure this can be refactored nicely (hint!), but this code below will construct a SQL which can be used in a sub-select.
Code below does not sanitize the input values.
Note that you should sanitize the values in the h hash!
h = {0=>{:name=>"Bob", :lastname=>"Presley"}, 1=>{:lastname=>"Cox"}, 2=>{:name=>"Lucy"}}
conditions = ""
h.each_pair do |k,v|
if not conditions.empty?
conditions += " or "
end
conditions += "("
a_condition = ""
v.each_pair do |a,b|
if not a_condition.empty?
a_condition += " and "
end
a_condition += "#{a.to_s} = '#{b}'"
end
conditions += a_condition
conditions += ")"
end
conditions = "("+conditions+")"
p conditions
# => "((name = 'Bob' and lastname = 'Presley') or (lastname = 'Cox') or (name = 'Lucy'))"
# use the generated SQL conditions to find the users
#users = User.find(:all, :conditions => "(#{conditions})")

Resources