ActiveRecord: How to do an 'OR' merge? - ruby-on-rails

I'm trying to decouple ActiveRecord queries in a model so they are reusable in different circumstances. To keep it simple, say I have a model called Product:
class Product < ActiveRecord::Base
def self.over_stocked
where('stock_count >= ?', 20)
end
def self.expensive
where('price >= ?', 100.0)
end
end
If I wanted to create a new method to find products that have too much stock AND are expensive, I could merge the two queries:
...
def self.costly_stock
# SQL => '... WHERE stock_count >= 20 AND price >= 100.0'
over_stocked.merge(expensive)
end
However how can I use these two methods to create a new query for products that are either expensive OR are over stocked?
E.g:
...
def expensive_or_over_stocked
# SQL => '... WHERE stock_count >= 20 OR price >= 100.0'
...
end
Basically I'm looking for something like merge that uses OR rather than AND. Ideally the solution would return an ActiveRecord Relation and not an Array. Obviously I could rewrite the query with where('stock_count >= ? OR price >= ?', 20, 100.0) however that wouldn't be very DRY

I came up with the following solution. One can argue how DRY it is.
class Product < ActiveRecord::Base
scope :over_stocked, -> { where.not(stock_count: [0..19]) }
scope :expensive, -> { where.not(price: [0..99]) }
scope :costly_stock, -> { expensive.over_stocked }
scope :neither_expensive_nor_over_stocked, -> { where(stock_count: [0..19]).where(price: [0..99]) }
def self.expensive_or_over_stocked
Product.where.not(id: Product.neither_expensive_nor_over_stocked.pluck(:id))
end
end

Related

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 to do a query with a two-value range and a scope, with Ruby on Rails?

I'm trying to get the results between two values in Rails. I am using scope in model. My model is like the following:
# Model ad.rb
scope :min_price, -> (number) { "ads.price = #{number}%" }
scope :max_price, -> (number) { "ads.price = #{number}%" }
And this is my controller
# Controller ads_controller.rb
def index
#ads = Ad.where((min_price(params[:min_price]))..(max_price(params[:max_price]))).order("created_at DESC") if params[:min_price].present? if params[:max_price].present?
end
This does not work. What's the right way?
You should combine them when using them together, otherwise you will have one overwrite the other (depending on how you call them in the query).
class Ad
scope :min_max_price ->(min,max) { where('price > ? AND price < ?', min, max) }
end
Or to do it separately:
class Ad
scope :min_price, ->(min) { where('price > ?', min) }
scope :max_price, ->(max) { where('price < ?', max) }
end
class AdController < ApplicationController
def index
#ads = Ad.min_price(params[:min]).max_price(params[:max])
end
end
Scopes are designed to be chained, not included in the args to a where list. In particular, when you call where ActiveRecord returns a relation object that corresponds to the query. When you chain off of it with where, limit, order, etc, it creates a new Relation object that is the combination of the chained methods.
When you finally access an object of the collection with something like all or each, that's when the actual query is constructed and sent on to the database.

Dynamic search form for multiple columns of the same model in Rails 4

I am trying to create a search form with multiple columns(all from the same model)
Now I want to set the value of all other columns as nil if the category column is set to a particular value. Something like this-
#app/models/question.rb
if (cateogry.matches => 'Exam Questions')
query = query.where("name like ? AND course like ? AND year like ?", "%#{query}%", "%#{query}%", "%#{query}%")
else
query = query.where("name like ?","%#{query}%")
end
The search form is a basic form using get method.
You could use scopes for that.
class Question < ActiveRecord::Base
...
# Scope for searching by exam
scope :by_exam, -> (name, course, year) {
match(:name, name).
match(:course).
match(:year, year)
}
# Scope forsearching by nma
scope :by_name, ->(name) { match(:name, name) }
# Helper scope for matching
scope :match, ->(key, value) { where(arel_table[key].matches("%#{value}%"} }
end
So in your controller
if (cateogry.matches => 'Exam Questions')
Question.by_exam(params[:name], params[:course], params[:year])
else
Question.by_name(params[:name])
end

Scope in where clause

You can see the last lines of these two models use the same code:
class Position < ActiveRecord::Base
scope :last_day,
where('positions.created_at > ? AND positions.hidden = ?',
DateTime.now.in_time_zone("Sofia").yesterday.beginning_of_day + 10.hours, false)
end
class Subscriber < ActiveRecord::Base
scope :notify_today,
joins(:tags => :positions).
where('positions.created_at > subscribers.created_at
AND positions.created_at > ? AND positions.hidden = ?',
DateTime.now.in_time_zone("Sofia").yesterday.beginning_of_day + 10.hours, false)
end
Is it possible to reuse the 'last_day' scope somehow in the second model?
where() and joins() both return ActiveRecord::Relation objects, and they have a method called merge() which can merge other ActiveRecord::Relation objects. Therefore, you can simply do this
scope :notify_today, joins(:tags => :positions).merge(Position.last_day)
Also, & is an alias to merge(), so you should also be able to do this
scope :notify_today, joins(:tags => :positions) & Position.last_day

How can I find records by "count" of association using rails and mongoid?

With these models:
class Week
has_many :proofs
end
class Proof
belongs_to :week
end
I want to do something like:
Week.where(:proof.count.gt => 0)
To find only weeks that have multiple proofs.
There is one answer that seems to address this:
Can rails scopes filter on the number of associated classes for a given field
But in this example, there is no such attribute as proof_ids in Week since the ids are stored with the proofs. This does not work for example:
Week.where(:proof_ids.gt => 0)
How is this query possible? Conceptually simple but I can't figure out how to do this with mongo or mongoid.
Similarly, I'd like to order by the number of proofs for example like:
Week.desc(:proofs.size)
But this also does not work.
I do realize that a counter-cache is an option to both my specific questions but I'd also like to be able to do the query.
Thanks in advance for any help.
With rails (and without counter_cache), you could do:
class Week < ActiveRecord::Base
has_many :proofs
def self.by_proofs_size
sort_by { |week| week.proofs.size }
end
def self.with_at_least_n_proofs(n = 1)
select { |week| week.proofs.size >= n }
end
end
Even though each of those operations produces 2 queries, this is far from ideal.
The pair of queries is repeated (=> 4 queries for each operation) with scopes (bug?):
scope :with_at_least_n_proofs, -> (n = 1) { select { |w| w.proofs.size >= n } }
scope :by_proofs_size, -> { sort_by { |w| w.proofs.size } }
The ideal is probably to use counter_cache
scope :with_at_least_n_proofs, -> (n = 1) { where('proofs_count >= ?', n) }
scope :by_proofs_size, -> { order(proofs_count: :desc) }
I don't know if this is the best solution, as it maps it through a array, but this does the job: (the other solutions mentioned here gives me exceptions)
class Week < ActiveRecord::Base
scope :has_proofs, -> { any_in(:_id => includes(:proofs).select{ |w| w.proofs.size > 0 }.map{ |r| r.id }) }
end
Pardon me if I'm way off - but would you be able to use a simple counter_cache in the weeks table? Then you could do something like week.proofs_count.

Resources