How to search for multiple strings in a database table column? - mysql

I am using Rails 3.2.2 and MySQL. I am searching by user name (for instances, John, Anthony, Mark) a database table column this way:
# User controller
User.search_by_name(params[:search]).order(:name)
# User model
def self.search_by_name(search)
if search
where('users.name LIKE ?', "%#{search}%")
else
scoped
end
end
However, since a name can be composed from two or more strings (for instances, John Henry or Henry John, Anthony Maria or Maria Anthony, Mark Alfred or Alfred Mark), I would like to search users also when in the params[:search] are provided more than one name. I tried to use
def self.search_by_name(search)
if search
search.split(' ').each do |string|
where('users.name LIKE ?', "%#{string}%")
end
else
scoped
end
end
but I get the following error (for instance, given I am searching for John Henry):
NoMethodError (undefined method `order' for ["John", "Henry"]:Array).
How can I properly search for multiple names?

I totally think you should do one of the following,
Mysql full text search:
http://devzone.zend.com/26/using-mysql-full-text-searching/
SphinxSearch:
http://www.ibm.com/developerworks/library/os-php-sphinxsearch/
I recommend sphinxsearch since I use it with various cool features
built in with it.
Support for sphinx is amazing too!

Using the squeel gem.
def self.search_by_name(search)
if search
where(name.like_any search.split)
else
scoped
end
end
Note that this will NOT order by matching level. For that you need a search-engine implementation, like gems for sphinx, solr, xapian
also note that your original use of each is incorrent since you meant to 'OR' the conditions. If you do not mind to issue as many db queries as search terms you could even fake the match level ordering.
def self.search_by_name(search)
if search
results = search.split.map do |string|
where('users.name LIKE ?', "%#{string}%").all
end.flatten
ids = results.map(&:id)
# desc order by count of matches
ordered_results = results.uniq.order { |result| -ids.count(result.id) }
else
scoped.all
end
end
This is not an Arel relation that can be further scoped but a plain array though.
Note the 'all' call, so do not even attempt this on a big db.
Also note that this will not order 'a b' above 'b a' if search is 'a b' etc.
So I'm kinda just having fun.

Related

Ruby - Rails - SQL query - reordering words in a search term

I'm working on a project with a search function that is set up as follows:
if params[:search_term].present? && params[:search_term].length > 1
#candidates = #candidates.where("title like ?","%#{params[:search_term]}%")
end
The client has asked me to 'loosen up' the search - specifically the word order. At the moment if there is a candidate with a title of White bar stool and one searches for White stool bar, it returns no results.
Is there any way for me to perform a query where word order is ignored? Or would it be better for me to make new search term params with a different word order, do multiple searches, and combine the results?
You may consider using Arel for this. Arel is the underlying query assembler for rails/activerecord (so no new dependencies) and can be very useful when building complex queries because it offers far more depth than the high level ActiveRecord::QueryMethods.
Arel offers a large selection of predication matchers including in your case matches_any and matches_all. These methods take an Array of Strings and split them into individual search conditions using Like.
For Example to search for all candidates that contain any of the words searched for you can use:
class Candidate < ActiveRecord::Base
def self.search_for(term)
candidates = Candidate.arel_table
where(
candidates[:title].lower.matches_any(
term.split.map { |t| "%#{t.downcase}%" }
)
)
end
end
The end result of search_for (given a search term of 'White stool bar') is:
SELECT [candidates].*
FROM [candidates]
WHERE (
LOWER([candidates].[title]) LIKE '%white%'
OR LOWER([candidates].[title]) LIKE '%stool%'
OR LOWER([candidates].[title]) LIKE '%bar%')
Which appears to be what you are looking for. If it must match all the terms you can instead use matches_all which will result in:
SELECT [candidates].*
FROM [candidates]
WHERE (
LOWER([candidates].[title]) LIKE '%white%'
AND LOWER([candidates].[title]) LIKE '%stool%'
AND LOWER([candidates].[title]) LIKE '%bar%')
See Here for all the available Arel predications.
This has added benefits of basic escaping to avoid things like SQL injection.
You can use the MySQL RLIKE operator, to match with an specific pattern you can create with your sentence.
sentence = 'White stoll bar'
#candidates = #candidates.where('title RLIKE ?', "(#{sentence.tr(' ', '|')})")

Using Concat within a Rails Scope

I have a Contact model. It has the attributes: first_name and last_name. The user enters text in a search field with the prompt: Enter the contact's full name. Rails then needs to find all contact records LIKE the name entered.
Here is a picture of the contact records:
-If the user types in "JOE" then rails will return two records (because it is case insensitive)
-If the user types in "joe s" then rails will return two records
-If the user types in "doe" then rails will return one record.
#models/contact.rb
class Contact < ActiveRecord::Base
scope :by_entered_name, -> (full_name){where("CONCAT('first_name',' ','last_name') LIKE ?", full_name)}
end
Generated sql when I run Contact.by_entered_name("joe") in the rails console:
SELECT "contacts".* FROM "contacts" WHERE (CONCAT('first_name',' ','last_name') LIKE 'joe'
I am using mysql in case that detail is important. For this example app however, I am using sqlite, and it is throwing a syntax error. Ultimately what is most important is that I get this to work on mysql.
Update: It was expressed that my question was not clear. My question is:
How do I properly create a query which takes text entered by a user, and finds all contacts whose concatenated first_name and last_name are LIKE that submitted text by the user? I also need it to be case insensitive. My attempted scope above does not appear to work.
There are some quotes in there you don't need. And you need the wildcard % in the parameter. Also, ILIKE is needed to disregard the case with Postgres.
class Contact < ActiveRecord::Base
scope :by_entered_name, -> (full_name){where("CONCAT(first_name,' ',last_name) ILIKE ?", "%#{full_name}%")}
end
SQLite doesn't use the CONCAT function, it uses || as a concatenation operator. Swards' answer will get you going in MySQL.

How to instruct Rails to generate the correct SQL on uniqueness validation when case insensitive

Assume Rails 3 with MySQL DB with Case Insensitive collation
What's the story:
Rails allows you to validate an attribute of a Model with the "uniqueness" validator. BUT the default comparison is CASE SENSITIVE according to Rails documentation.
Which means that on validation it executes SQL like the following:
SELECT 1 FROM `Users` WHERE (`Users`.`email` = BINARY 'FOO#email.com') LIMIT 1
This works completely wrong for me who has a DB with CI Collation. It will consider the 'FOO#email.com' valid, even if there is another user with 'foo#email.com' already in Users table. In other words, this means, that if the user of the application tries to create a new User with email 'FOO#email.com' this would have been completely VALID (by default) for Rails and INSERT will be sent to db. If you do not happen to have unique index on e-mail then you are boomed - row will be inserted without problem. If you happen to have a unique index, then exception will be thrown.
Ok. Rails says: since your DB has case insensitive collation, carry out a case insensitive uniqueness validation.
How is this done? It tells that you can override the default uniqueness comparison sensitivity by setting ":case_sensitive => false" on the particular attribute validator. On validation it creates the following SQL:
SELECT 1 FROM `Users` WHERE (LOWER(`Users`.`email`) = LOWER('FOO#email.com') LIMIT 1
which is a DISASTER on a database table Users that you have designed to have a unique index on the email field, because it DOES NOT USE the index, does full table scan.
I now see that the LOWER functions in SQL are inserted by the UniquenessValidator of ActiveRecord (file uniqueness.rb, module ActiveRecord, module Validations class UniquenessValidator). Here is the piece of code that does this:
if value.nil? || (options[:case_sensitive] || !column.text?)
sql = "#{sql_attribute} #{operator}"
else
sql = "LOWER(#{sql_attribute}) = LOWER(?)"
end
So Question goes to Rails/ActiveRecord and not to MySQL Adapter.
QUESTION: Is there a way to tell Rails to pass the requirement about uniqueness validation case sensitivity to MySQL adapter and not be 'clever' about it to alter the query? OR
QUESTION REPHRASED FOR CLARIFICATION: Is there another way to implement uniqueness validation on an attribute (PLEASE, CAREFUL...I AM NOT TALKING ABOUT e-mail ONLY, e-mail was given as an example) with case sensitivity OFF and with generation of a query that will use a simple unique index on the corresponding column?
These two questions are equivalent. I hope that now, I make myself more clear in order to get more accurate answers.
Validate uniqueness without regard to case
If you want to stick to storing email in upper or lower case then you can use the following to enforce uniqueness regardless of case:
validates_uniqueness_of :email, case_sensitive: false
(Also see this question:
Rails "validates_uniqueness_of" Case Sensitivity)
Remove the issue of case altogether
Rather than doing a case insensitive match, why not downcase the email before validating (and therefore also):
before_validation {self.email = email.downcase}
Since case is irrelevant to email this will simplify everything that you do as well and will head off any future comparisons or database searches you might be doing
I have searched around and the only answer, according to my knowledge today, that can be acceptable is to create a validation method that does the correct query and checks. In other words, stop using :uniqueness => true and do something like the following:
class User
validate :email_uniqueness
protected
def email_uniqueness
entries = User.where('email = ?', email)
if entries.count >= 2 || entries.count == 1 && (new_record? || entries.first.id != self.id )
errors[:email] << _('already taken')
end
end
end
This will definitely use my index on email and works both on create and update (or at least it does up to the point that I have tested that ok).
After asking on the RubyOnRails Core Google group
I have taken the following answer from RubyOnRails Core Google Group: Rails is fixing this problem on 3.2. Read this:
https://github.com/rails/rails/commit/c90e5ce779dbf9bd0ee53b68aee9fde2997be123
Workaround
If you want a case-insensitive comparison do:
SELECT 1 FROM Users WHERE (Users.email LIKE 'FOO#email.com') LIMIT 1;
LIKE without wildcards always works like a case-insensitive =.
= can be either case sensitive or case-insensitive depending on various factors (casting, charset...)
starting with http://guides.rubyonrails.org/active_record_querying.html#finding-by-sql
then adding their input
#Johan,
#PanayotisMatsinopoulos
and this
http://guides.rubyonrails.org/active_record_validations_callbacks.html#custom-methods
and http://www.w3schools.com/sql/sql_like.asp
then we have this:
class User < ActiveRecord::Base
validate :email_uniqueness
protected
def email_uniqueness
like_emails = User.where("email LIKE ?", email))
if (like_emails.count >= 2 || like_emails.count == 1
&& (new_record? || like_emails.first.id != self.id ))
errors[:email] << _('already taken')
end
end
end
validates :email, uniqueness: {case_sensitive: false}
Works like a charm in Rails 4.1.0.rc2
;)
After fighting with MySQL binary modifier, i found a way that removes that modifier from all queries comparing fields (not limited to uniqueness validation, but includes it).
First: Why MySQL adds that binary modifier? That's because by default MySQL compares fields in a case-insensitive way.
Second: Should I care? I always had design my systems to suppose that String comparison are made in a case-insensitive way, so that is a desired feature to me. Be warned if you don't
This is where is added the binary modifier:
https://github.com/rails/rails/blob/ee291b9b41a959e557b7732100d1ec3f27aae4f8/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L545
def case_sensitive_modifier(node)
Arel::Nodes::Bin.new(node)
end
So i override this. I create an initializer (at config/initializers) named "mysql-case-sensitive-override.rb" with this code:
# mysql-case-sensitive-override.rb
class ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter < ActiveRecord::ConnectionAdapters::AbstractAdapter
def case_sensitive_modifier(node)
node
end
end
And that's it. No more binary modifier on my queries :D
Please notice that this does not explain why the "{case_sensitive: false}" option of the validator doesn't works, and does not solves it. It changes the default-and-unoverrideable-case-sensitive behavior for a default-and-unoverrideable-case-insensitive new behavior. I must insist, this also changes for any comparison that actually uses binary modifier for case-sensitive behavior (i hope).

Multiple word searching with Ruby, and MySQL

I'm trying to accomplish a multiple word searching in a quotes database using Ruby, ActiveRecord, and MySQL. The way I did is shown bellow, and it is working, but I would like to know if there a better way to do.
# receives a string, splits it in a array of words, create the 'conditions'
# query, and send it to ActiveRecord
def search
query = params[:query].strip.split if params[:query]
like = "quote LIKE "
conditions = ""
query.each do |word|
conditions += (like + "'%#{word}%'")
conditions += " AND " unless query.last == word
end
#quotes = Quote.all(:conditions => conditions)
end
I would like to know if there is better way to compose this 'conditions' string. I also tried it using string interpolation, e.g., using the * operator, but ended up needing more string processing. Thanks in advance
First, I strongly encourage you to move Model's logic into Models. Instead of creating the search logic into the Controller, create a #search method into your Quote mode.
class Quote
def self.search(query)
...
end
end
and your controller becomes
# receives a string, splits it in a array of words, create the 'conditions'
# query, and send it to ActiveRecord
def search
#quotes = Quote.search(params[:query])
end
Now, back to the original problem. Your existing search logic does a very bad mistake: it directly interpolates value opening your code to SQL injection. Assuming you use Rails 3 you can take advantage of the new #where syntax.
class Quote
def self.search(query)
words = query.to_s.strip.split
words.inject(scoped) do |combined_scope, word|
combined_scope.where("quote LIKE ?", "%#{word}%")
end
end
end
It's a little bit of advanced topic. I you want to understand what the combined_scope + inject does, I recommend you to read the article The Skinny on Scopes.
MySQL fulltext search not working, so best way to do this:
class Quote
def self.search_by_quote(query)
words = query.to_s.strip.split
words.map! { |word| "quote LIKE '%#{word}%'" }
sql = words.join(" AND ")
self.where(sql)
end
end
The better way to do it would be to implement full text searching. You can do this in MySQL but I would highly recommend Solr. There are many resources online for implementing Solr within rails but I would recommend Sunspot as an entrance point.
Create a FULLTEXT index in MySQL. With that, you can leave string processing to MySQL.
Example : http://dev.mysql.com/doc/refman/5.0/en/fulltext-search.html

MySQL syntax in Rails 3, NOT Case sensitive searching, not working on Heroku?

I've been following the Railscast for adding searching, sorting, and pagination to my app. I've modified my search logic in the model to search to columns (description and title). This seems to be working. I've also changed it to search non case sensitive. This seems to work perfect in my local app, but when I push it to heroku, I can only search by lowercase. Search with any capital letters at all produces no results, even if the case matches the results.
here is the code in my model:
def self.search(search)
if search
where('LOWER (description) LIKE ? OR LOWER (title) LIKE ?', "%#{search}%" , "%#{search}%")
else
scoped
end
end
Try
where("LOWER (description) LIKE ? OR LOWER (title) LIKE ?", "%#{search.downcase}%" , "%#{search.downcase}%")
I had this problem, too. Heroku uses PostgreSQL and the LIKE-Operator there is strongly typed to strings. Check this http://www.postgresql.org/docs/8.3/interactive/functions-matching.html