Dynamic query with multiple OR conditions - ruby-on-rails

On an ActiveRecord model, I'm trying to dynamically create a query that has multiple OR conditions. ie:
SELECT * from articles
WHERE (name LIKE '%a%' OR desc LIKE '%a%' OR
name LIKE '%b%' OR desc LIKE '%b%' OR
name LIKE '%c%' OR desc LIKE '%c%')
I think I'm on the right track using arel, but I can't work out how to start off the query.
class Article < ActiveRecord::Base
attr_accessor :title, :text
def self.search(terms)
terms = *terms
t = self.arel_table
query = terms.reduce(???) do |query, word|
search_term = "%#{word}%"
query.or(t[:title].matches(search_term).or(t[:text].matches(search_term)).expr).expr
end
where(query)
end
end
I originally got the idea from this answer, but the original query is a string obviously and not something I can chuck .or onto.
What do I need to replace ??? in the reduce method to make this work, or do I need to take a completely different path (as I suspect)?

This is what I did to get it working:
class Article < ActiveRecord::Base
attr_accessor :title, :text
def self.search(terms)
terms = *terms
t = self.arel_table
# generate array of conditions
query = terms.collect do |word|
search_term = "%#{word}%"
t[:title].matches(search_term).or(t[:text].matches(search_term)).expr
end
# combine conditions
query = query.reduce {|query, condition| query.or(condition).expr }
where(query)
end
end

Related

How to use find_by_sql properly?

I have the following model
class Backup < ActiveRecord::Base
belongs_to :component
belongs_to :backup_medium
def self.search(value)
join_tables = "backups, components, backup_media"
joins = "backups.backup_medium_id = backup_media.id and components.id = backups.component_id"
c = Backup.find_by_sql "select * from #{join_tables} where components.name like '%#{value}%' and #{joins}"
b = Backup.find_by_sql "select * from #{join_tables} where backup_media.name like '%#{value}%' and #{joins}"
c.count > 0 ? c : b
end
end
In pry, when I run Backup.all.class, I get
=> Backup::ActiveRecord_Relation
but when I run Backup.search('xxx').class, I get
=> Array
Since the search should return a subset of all, I think I need to return an Active Record_Relation. What am I missing?
From the documentation:
Executes a custom SQL query against your database and returns all the
results. The results will be returned as an array with columns
requested encapsulated as attributes of the model you call this method
from. If you call Product.find_by_sql then the results will be
returned in a Product object with the attributes you specified in the
SQL query.
So you will get an array of Backup instances.
Note that you probably should not do it this way. Using string interpolation in a query opens you up to SQL injection attacks and gains you nothing. Also, you can get quite a bit more flexibility using ActiveRecord scopes for this.
def self.my_includes
includes(:components, :backup_media)
end
def self.by_component_name(name)
media_includes.where("components.name like ?", "'%#{name}%'")
end
def self.by_media_name(name)
media_includes.where("backup_media.name like ?", "'%#{value}%'")
end
def self.search(name)
by_component(name).any? ? by_component_name : by_media_name
end
You can then call
Backup.search(name)
as well as
Backup.by_component_name(name)
or
Backup.by_media_name(name)
find_by_sql returns an array of objects, not a Relation. If you want to return relation for consistency try to rewrite your search to use ActiveRecord api:
def self.search(value)
query = Backup.includes(:component, :backup_medium)
by_component_name = query.where("components.name like ?", "'%#{value}%'")
by_media_name = query.where("backup_media.name like ?", "'%#{value}%'")
by_component_name.any? ? by_component_name : by_media_name
end
or, if you still want to use sql, you can try to fetch record ids and then make a second query:
def self.search(value)
# ...
c = Backup.find_by_sql "select id from #{join_tables} where components.name like '%#{value}%' and #{joins}"
b = Backup.find_by_sql "select id from #{join_tables} where backup_media.name like '%#{value}%' and #{joins}"
ids = c.count > 0 ? c : b
Backup.where(id: ids)
end
So I am unable to get the syntax right for the media_includes, but inspired by your solution I have succeeded by using joins.
I created a small demo project which just shows the code related to search. You can take a look at https://github.com/pamh09/rails-search-demo. If you want to collaborate on a solution, I think this would be more efficient than trying to paste all the code here. That said, I do have a working solution if you'd rather not bother. But I would like to see what the right syntax is.
Below is the model code. It's very possible that I just have some kind of syntactic mismatch since I am not very familiar with how rails does its database magic (obviously).
class Backup < ApplicationRecord
belongs_to :component
belongs_to :backup_medium
#---- code below does not work ---
# in pry
# pry(Backup):1> by_media('bak').any?
# (0.0ms) SELECT COUNT(*) FROM "backups" WHERE (backup_media = 'bak')
# ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: backup_media.name: SELECT COUNT(*) FROM "backups" WHERE (backup_media.name = 'bak')
def self.my_includes
includes(:component, :backup_medium)
end
def self.by_component(name)
my_includes.where("components.name = ?", name)
end
def self.by_media(name)
my_includes.where("backup_media.name = ?", name)
end
def self.search_by(name)
by_component(name).any? ? by_component_name : by_media_name
end
# ----- code below works ... call search('string') -----
# I was unable to get the like query to work without using #{name}
def self.by_component_like(name)
# Note: joins (singular).where (plural.column ...)
joins(:component).where("components.name like '%#{name}%'")
end
def self.by_media_like(name)
joins(:backup_medium).where("backup_media.name like '%#{name}%'")
end
def self.search(name)
by_component_like(name).any? ? by_component_like(name) : by_media_like(name)
end
end
And, as noted in the code. I could not figure you how to use the ? with LIKE as the query would come in as LIKE '%'xxx'%' instead of '%xxx%'.

How to use `join` method to access columns from multiple tables in rails

I have two tables, and I want to display all these columns on page.
Tables:
1.Users:
name, email, sex_id
abc, abc#q.com, 0
2. Masters:
type, sex, sexn
8, 0, female
I want to display:
name, email, sex
abc, abc#q.com, femail
Models' definition:
class Master < ApplicationRecord
has_many :users
end
class User < ApplicationRecord
belongs_to :master
def self.search(search)
where("name LIKE ?", "%#{search}%")
end
end
using #users = User.joins("INNER JOIN masters ON masters.sex = users.sex_id AND masters.type = 8"), I can only access columns from Users.
I want to access data from Masters. Using #users.first.master, I just get nil.
using#users = User.find_by_sql("SELECT * FROM users INNER JOIN masters ON masters.sex = users.sex_id AND masters.type = 8"), I can access columns from these two tables. So there's no problem with my data.
How do I use join method to access columns from multiple tables?
You have missed the select statement, try this one.
#users = User.select("users.*, masters.*").joins("INNER JOIN masters ON masters.sex = users.sex_id AND masters.type = 8")
You're almost there! You can have Rails do the join for you with the .includes() method:
def self.search(search)
# :master is singular
includes(:master).where("name LIKE ?", "%#{search}%")
end
Then:
#users.each do |user|
# singular again
puts user.master.sexn
end

Specific search implementation

So I'm trying to improve the search feature for my app
My model relationships/associations are like so (many>one, one=one):
Clients < Projects < Activities = Assignments = Users
Assignments < Tasks
Tasks table has only a foreign key to assignments.
Search params look something like this:
params[:search]==User: 'user_handle', Client: 'client_name', Project: 'project_name', Activity: 'activity_name'
So I need to porbably search Clients.where().tasks, Projects.where().tasks and so on.
Then I need to somehow concatenate those queries and get rid of all the duplicate results. How to do that in practice however, I have no clue.
I've been hitting my head against a brick wall with this and internet searches didn't really help... so any help is greatly apreciated. Its probably a simple solution too...
I am on rails 4.2.5 sqlite for dev pg for production
A few things I would change/recommend based on the code in your own answer:
Move the search queries into scopes on each model class
Prefer AREL over raw SQL when composing queries (here's a quick
guide)
Enhance rails to use some sort of or when querying Models
The changes I suggest will enable you to do something like this:
search = search_params
tasks = Tasks.all
tasks = tasks.or.user_handle_matches(handle) if (handle = search[:user].presence)
tasks = tasks.or.client_name_matches(name) if (name = search[:client].presence)
tasks = tasks.or.project_name_matches(name) if (name = search[:project].presence)
tasks = tasks.or.activity_name_matches(name) if (name = search[:activity].presence)
#tasks = tasks.uniq
First, convert each of your queries to a scope on your models. This enables you to reuse your scopes later:
class User
scope :handle_matches, ->(handle) {
where(arel_table[:handle].matches("%#{handle}%"))
}
end
class Client
scope :name_matches, ->(name) {
where(arel_table[:name].matches("%#{name}%"))
}
end
class Project
scope :name_matches, ->(name) {
where(arel_table[:name].matches("%#{name}%"))
}
end
class Activity
scope :name_matches, ->(name) {
where(arel_table[:name].matches("%#{name}%"))
}
end
You can then use these scopes on your Task model to allow for better searching capabilities. For each of the scopes on Task we are doing an join (inner join) on a relationship and using the scope to limit the results of the join:
class Task
belongs_to :assignment
has_one :user, :through => :assignment
has_one :activity, :through => :assignment
has_one :project, :through => :activity
scope :user_handle_matches, ->(handle) {
joins(:user).merge( User.handle_matches(handle) )
}
scope :client_name_matches, ->(name) {
joins(:client).merge( Client.name_matches(name) )
}
scope :activity_name_matches, ->(name) {
joins(:activity).merge( Activity.name_matches(name) )
}
scope :project_name_matches, ->(name) {
joins(:project).merge( Project.name_matches(name) )
}
end
The final problem to solve is oring the results. Rails 4 and below don't really allow this out of the box but there are gems and code out there to allow this functionality.
I often include the code in this GitHub gist in an initializer to allow oring of scopes. The code allows you to do things like Person.where(name: 'John').or.where(name: 'Jane').
Many other options are discussed in this SO question.
If you don't want include random code and gems, another option is to pass an array of ids into the where clause. This generates a query similar to SELECT * FROM tasks WHERE id IN (1, 4, 5, ...):
tasks = []
tasks << Tasks.user_handle_matches(handle) if (handle = search[:user].presence)
tasks << tasks.or.client_name_matches(name) if (name = search[:client].presence)
tasks << tasks.or.project_name_matches(name) if (name = search[:project].presence)
tasks << tasks.or.activity_name_matches(name) if (name = search[:activity].presence)
# get the matching id's for each query defined above
# this is the catch, each call to `pluck` is another hit of the db
task_ids = tasks.collect {|query| query.pluck(:id) }
tasks_ids.uniq!
#tasks = Tasks.where(id: tasks_ids)
So I solved it, it is supper sloppy however.
first I wrote a method
def add_res(ar_obj)
ar_obj.each do |o|
res += o.tasks
end
return res
end
then I wrote my search logic like so
if !search_params[:user].empty?
query = add_res(User.where('handle LIKE ?', "%#{search_params[:user]}%"))
#tasks.nil? ? #tasks=query : #tasks=#tasks&query
end
if !search_params[:client].empty?
query = add_res(Client.where('name LIKE ?', "%#{search_params[:client]}%"))
#tasks.nil? ? #tasks=query : #tasks=#tasks&query
end
if !search_params[:project].empty?
query = add_res(Project.where('name LIKE ?', "%#{search_params[:project]}%"))
#tasks.nil? ? #tasks=query : #tasks=#tasks&query
end
if !search_params[:activity].empty?
query = add_res(Activity.where('name LIKE ?', "%#{search_params[:activity]}%"))
#tasks.nil? ? #tasks=query : #tasks=#tasks&query
end
if #tasks.nil?
#tasks=Task.all
end
#tasks=#tasks.uniq
If someone can provide a better answer I would be forever greatful

How do I combine results from two queries on the same model?

I need to return exactly ten records for use in a view. I have a highly restrictive query I'd like to use, but I want a less restrictive query in place to fill in the results in case the first query doesn't yield ten results.
Just playing around for a few minutes, and this is what I came up with, but it doesn't work. I think it doesn't work because merge is meant for combining queries on different models, but I could be wrong.
class Article < ActiveRecord::Base
...
def self.listed_articles
Article.published.order('created_at DESC').limit(25).where('listed = ?', true)
end
def self.rescue_articles
Article.published.order('created_at DESC').where('listed != ?', true).limit(10)
end
def self.current
Article.rescue_articles.merge(Article.listed_articles).limit(10)
end
...
end
Looking in console, this forces the restrictions in listed_articles on the query in rescue_articles, showing something like:
Article Load (0.2ms) SELECT `articles`.* FROM `articles` WHERE (published = 1) AND (listed = 1) AND (listed != 1) ORDER BY created_at DESC LIMIT 4
Article Load (0.2ms) SELECT `articles`.* FROM `articles` WHERE (published = 1) AND (listed = 1) AND (listed != 1) ORDER BY created_at DESC LIMIT 6 OFFSET 4
I'm sure there's some ridiculously easy method I'm missing in the documentation, but I haven't found it yet.
EDIT:
What I want to do is return all the articles where listed is true out of the twenty-five most recent articles. If that doesn't get me ten articles, I'd like to add enough articles from the most recent articles where listed is not true to get my full ten articles.
EDIT #2:
In other words, the merge method seems to string the queries together to make one long query instead of merging the results. I need the top ten results of the two queries (prioritizing listed articles), not one long query.
with your initial code:
You can join two arrays using + then get first 10 results:
def self.current
(Article.listed_articles + Article.rescue_articles)[0..9]
end
I suppose a really dirty way of doing it would be:
def self.current
oldest_accepted = Article.published.order('created_at DESC').limit(25).last
Artcile.published.where(['created_at > ?', oldest_accepted.created_at]).order('listed DESC').limit(10)
end
If you want an ActiveRecord::Relation object instead of an Array, you can use:
ActiveRecordUnion gem.
Install gem: gem install active_record_union and use:
def self.current
Article.rescue_articles.union(Article.listed_articles).limit(10)
end
UnionScope module.
Create module UnionScope (lib/active_record/union_scope.rb).
module ActiveRecord::UnionScope
def self.included(base)
base.send :extend, ClassMethods
end
module ClassMethods
def union_scope(*scopes)
id_column = "#{table_name}.id"
if (sub_query = scopes.reject { |sc| sc.count == 0 }.map { |s| "(#{s.select(id_column).to_sql})" }.join(" UNION ")).present?
where "#{id_column} IN (#{sub_query})"
else
none
end
end
end
end
Then call it in your Article model.
class Article < ActiveRecord::Base
include ActiveRecord::UnionScope
...
def self.current
union_scope(Article.rescue_articles, Article.listed_articles).limit(10)
end
...
end
All you need to do is sum the queries:
result1 = Model.where(condition)
result2 = Model.where(another_condition)
# your final result
result = result1 + result2
I think you can do all of this in one query:
Article.published.order('listed ASC, created_at DESC').limit(10)
I may have the sort order wrong on the listed column, but in essence this should work. You'll get any listed items first, sorted by created_at DESC, then non-listed items.

Dynamic "OR" conditions in Rails 3

I am working on a carpool application where people can search for lifts. They should be able to select the city from which they would liked to be picked up and choose a radius which will then add the cities in range to the query. However the way it is so far is that i can only chain a bunch of "AND" conditions together where it would be right to say "WHERE start_city = city_from OR start_city = a_city_in_range OR start_city = another_city_in_range"
Does anyone know how to achive this? Thanks very much in advance.
class Search < ActiveRecord::Base
def find_lifts
scope = Lift.where('city_from_id = ?', self.city_from)
#returns id of cities which are in range of given radius
#cities_in_range_from = City.location_ids_in_range(self.city_from, self.radius_from)
#adds where condition based on cities in range
for city in #cities_in_range_from
scope = scope.where('city_from_id = ?', city)
#something like scope.or('city_from_id = ?', city) would be nice..
end
end
You can use "IN" operator instead of "=" without using SQL syntax
With Arel (used by Rails 3.0) you can do that this way
class Search < ActiveRecord::Base
def find_lifts
#returns id of cities which are in range of given radius
#cities_in_range_from = City.location_ids_in_range(self.city_from, self.radius_from)
scope = Lift.where{ :city_from_id => [self.city_from] + #cities_in_range_from })
# ... and so on
end
end

Resources