Best way to conditionally chain scopes - ruby-on-rails

I'm trying to extend the functionality of my serverside datatable. I pass some extra filters to my controller / datatable, which I use to filter results. Currently in my model I am testing whether the params are present or not before applying my scopes, but I'm not convinced this is the best way since I will have a lot of if/else scenario's when my list of filters grows. How can I do this the 'rails way'?
if params[:store_id].present? && params[:status].present?
Order.store(params[:store_id]).status(params[:status])
elsif params[:store_id].present? && !params[:status].present?
Order.store(params[:store_id])
elsif !params[:store_id].present? && params[:status].present?
Order.status(params[:status])
else
Order.joins(:store).all
end
ANSWER:
Combined the answers into this working code:
query = Order.all
query = query.store(params[:store_id]) if params[:store_id].present?
query = query.status(params[:status]) if params[:status].present?
query.includes(:store)

You could do it like this:
query = Order
query = query.store(params[:store_id]) if params[:store_id].present?
query = query.status(params[:status]) if params[:status].present?
query = Order.joins(:store) if query == Order
Alternatively, you could also just restructure the status and store scopes to include the condition inside:
scope :by_status, -> status { where(status: status) if status.present? }
Then you can do this instead:
query = Order.store(params[:store_id]).by_status(params[:status])
query = Order.joins(:store) unless (params.keys & [:status, :store_id]).present?

Since relations are chainable, it's often helpful to "build up" your search query. The exact pattern for doing that varies widely, and I'd caution against over-engineering anything, but using plain-old Ruby objects (POROs) to build up a query is common in most of the large Rails codebases I've worked in. In your case, you could probably get away with just simplifying your logic like so:
relation = Order.join(:store)
if params[:store_id]
relation = relation.store(params[:store_id])
end
if params[:status]
relation = relation.status(params[:status])
end
#orders = relation.all
Rails even provides ways to "undo" logic that has been chained previously, in case your needs get particularly complex.

The top answer above worked for me. Here is an example of its' real-life implementation:
lessons = Lesson.joins(:member, :office, :group)
if #member.present?
lessons = lessons.where(member_id: #member)
end
if #office.present?
lessons = lessons.where(office_id: #office)
end
if #group.present?
lessons = lessons.where(group_id: #group)
end
#lessons = lessons.all

Related

ActiveRecord how to use Where only if the parameter you're querying has been passed?

I'm running a query like the below:
Item.where("created_at >=?", Time.parse(params[:created_at])).where(status_id: params[:status_id])
...where the user can decide to NOT provide a parameter, in which case it should be excluded from the query entirely. For example, if the user decides to not pass a created_at and not submit it, I want to run the following:
Item.where(status_id: params[:status_id])
I was thinking even if you had a try statement like Time.try(:parse, params[:created_at]), if params[created_at] were empty, then the query would be .where(created_at >= ?", nil) which would NOT be the intent at all. Same thing with params[:status_id], if the user just didn't pass it, you'd have a query that's .where(status_id:nil) which is again not appropriate, because that's a valid query in itself!
I suppose you can write code like this:
if params[:created_at].present?
#items = Item.where("created_at >= ?", Time.parse(params[:created_at])
end
if params[:status_id].present?
#items = #items.where(status_id: params[:status_id])
end
However, this is less efficient with multiple db calls, and I'm trying to be more efficient. Just wondering if possible.
def index
#products = Product.where(nil) # creates an anonymous scope
#products = #products.status(params[:status]) if params[:status].present?
#products = #products.location(params[:location]) if params[:location].present?
#products = #products.starts_with(params[:starts_with]) if params[:starts_with].present?
end
You can do something like this. Rails is smart in order to identify when it need to build query ;)
You might be interested in checking this blog It was very useful for me and can also be for you.
If you read #where documentation, you can see option to pass nil to where clause.
blank condition :
If the condition is any blank-ish object, then #where is a no-op and returns the current relation.
This gives us option to pass conditions if valid or just return nil will produce previous relation itself.
#items = Item.where(status_condition).where(created_at_condition)
private
def status_condition
['status = ?', params[:status]] unless params[:status].blank?
end
def created_at_condition
['created_at >= ?', Time.parse(params[:created_at])] unless params[:created_at].blank?
end
This would be another option to achieve the desired result. Hope this helps !

Rails Eager loading has_many associations for an existing object

I am fairly new to rails & I am having this performance issue that I would appreciate any help with.
I have a User model & each user has_many UserScores associated. I am preparing a dashboard showing different user stats including counts of user_scores based on certain conditions. Here is a snippet of the code:
def dashboard
#users = Array.new
users = User.order('created_at ASC')
users.each do |u|
user = {}
user[:id] = u.id
user[:name] = u.nickname
user[:email] = u.email
user[:matches] = u.user_scores.count
user[:jokers_used] = u.user_scores.where(:joker => true).length
user[:jokers] = u.joker
user[:bonus] = u.user_scores.where(:bonus => 1).length
user[:joined] = u.created_at.strftime("%y/%m/%d")
if user[:matches] > 0
user[:last_activity] = u.user_scores.order('updated_at DESC').first.updated_at.strftime("%y/%m/%d")
else
user[:last_activity] = u.updated_at.strftime("%y/%m/%d")
end
#users << user
end
#user_count = #users.count
end
The issue I am seeing is repeated UserScore db queries for each user to get the different counts.
Is there a way to avoid those multiple queries??
N.B. I'm not sure if my approach for preparing data for the view is the optimal way, so any advice or tips regarding that will be greatly appreciated as well.
Thanks
You need to eager load users_scores to reduce multiple queries. #Slava.K provided good explanation on how to eliminate that.
Add includes(:user_scores) for querying users, and use ruby's methods to work with collections once data is fetched from DB through query.
See code below to understand that:
users = User.includes(:user_scores).order('created_at ASC')
users.each do |u|
....
user[:matches] = u.user_scores.length
user[:jokers_used] = u.user_scopes.select{ |score| score.joker == true }.length
user[:jokers] = u.joker
user[:bonus] = u.user_scores.select{ |score| score.bonus == 1 }.length
....
end
Also, The way you are preparing response is not clean and flexible. Instead you should override as_json method to prepare json which can consumed by views properly. as_json method is defined for models by default. You can read more about it from official documentation http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html or visit article on preparing clean json response where I explained about overriding as_json properly in-depth.
Use includes method for eager loading your has many associations. You can understand this concept here: https://www.youtube.com/watch?v=s2EPVMqOsTQ
Firstly, reference user_scores association in your query:
users = User.includes(:user_scores).order('created_at ASC')
Follow rails documentation associations eager loading: http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
Also note that where makes new query to the database even if your association is already preloaded. Therefore, instead of
u.user_scores.where(:joker => true).length
u.user_scores.where(:bonus => 1).length
try:
u.user_scores.count { |us| us.joker }
u.user_scores.count { |us| us.bonus == 1 }
You will probably have to rewrite .user_scores.order('updated_at DESC').first.updated_at.strftime("%y/%m/%d") somehow as well

removing objects from an array during a loop

I am trying to filter the results of an user search in my app to only show users who are NOT friends. My friends table has 3 columns; f1 (userid of person who sent request), f2 (userid of friend who received request), and confirmed (boolean of true or false). As you can see, #usersfiltered is the result of the search. Then the definition of the current user's friend is established. Then I am trying to remove the friends from the search results. This does not seem to be working but should be pretty straight forward. I've tried delete (not good) and destroy.
def index
#THIS IS THE SEARCH RESULT
#usersfiltered = User.where("first_name LIKE?", "%#{params[:first_name]}%" )
#THIS IS DEFINING ROWS ON THE FRIEND TABLE THAT BELONG TO CURRENT USER
#confirmedfriends = Friend.where(:confirmed => true)
friendsapproved = #confirmedfriends.where(:f2 => current_user.id)
friendsrequestedapproved = #confirmedfriends.where(:f1 => current_user.id)
#GOING THROUGH SEARCH RESULTS
#usersfiltered.each do |usersfiltered|
if friendsapproved.present?
friendsapproved.each do |fa|
if usersfiltered.id == fa.f1
#NEED TO REMOVE THIS FROM RESULTS HERE SOMEHOW
usersfiltered.remove
end
end
end
#SAME LOGIC
if friendsrequestedapproved.present?
friendsrequestedapproved.each do |fra|
if usersfiltered.id == fra.f2
usersfiltered.remove
end
end
end
end
end
I would flip it around the other way. Take the logic that is loop-invariant out of the loop, which gives a good first-order simplification:
approved_ids = []
approved_ids = friendsapproved.map { |fa| fa.f1 } if friendsapproved.present?
approved_ids += friendsrequestedapproved.map { |fra| fra.f2 } if friendsrequestedapproved.present?
approved_ids.uniq! # (May not be needed)
#usersfiltered.delete_if { |user| approved_ids.include? user.id }
This could probably be simplified further if friendsapproved and friendsrequestedapproved have been created separately strictly for the purpose of the deletions. You could generate a single friendsapproval list consisting of both and avoid unioning id sets above.
While I agree that there may be better ways to implement what you're doing, I think the specific problem you're facing is that in Rails 4, the where method returns an ActiveRecord::Relation not an Array. While you can use each on a Relation, you cannot in general perform array operations.
However, you can convert a Relation to an Array with the to_a method as in:
#usersfiltered = User.where("first_name LIKE?", "%#{params[:first_name]}%" ).to_a
This would then allow you to do the following within your loop:
usersfiltered.delete(fa)

How to refactor complex search logic in a Rails model

My search method is smelly and bloated, and I need some help refactoring it. I'm new to Ruby, and I haven't figured out how to leverage it effectively, which leads to bloated methods like this:
# discussion.rb
def self.search(params)
# If there is a search query, use Tire gem for fulltext search
if params[:query].present?
tire.search(load: true) do
query { string params[:query] }
end
# Otherwise grab all discussions based on category and/or filter
else
# Grab all discussions and include the author
discussions = self.includes(:author)
# Filter by category if there is one specified
discussions = discussions.where(category: params[:category]) if params[:category]
# If params[:filter] is provided, user it
if params[:filter]
case params[:filter]
when 'hot'
discussions = discussions.open.order_by_hot
when 'new'
discussions = discussions.open.order_by_new
when 'top'
discussions = discussions.open.order_by_top
else
# If params[:filter] does not match the above three states, it's probably a status
discussions = discussions.order_by_new.where(status: params[:filter])
end
else
# If no filter is passed, just grab discussions by hot
discussions = discussions.open.order_by_hot
end
end
end
STATUSES = {
question: %w[answered],
suggestion: %w[started completed declined],
problem: %w[solved]
}
scope :order_by_hot, order('...') DESC, created_at DESC")
scope :order_by_new, order('created_at DESC')
scope :order_by_top, order('votes_count DESC, created_at DESC')
This is a Discussion model that can be filtered (or not) by a category: question, problem, suggestion.
All discussions or a single category can be filtered further by hot, new, votes, or status. Status is a hash in the model and it has several values depending on the category (status filter only appears if params[:category] is present).
Complicating matters is a fulltext search feature using Tire
But my controller looks nice and tidy:
def index
#discussions = Discussion.search(params)
end
Can I dry this up/refactor it a little, maybe using meta programming or blocks? I managed to extract this out of the controller, but then ran out of ideas. I don't know Ruby well enough to take this further.
For starters, "Grab all discussions based on category and/or filter" can be a separate method.
params[:filter] is repeated many times, so take that out at the top:
filter = params[:filter]
You can use
if [:hot, :new, :top].incude? filter
discussions = discussions.open.send "order_by_#{filter}"
...
Also, factor out if then else if case else statements. I prefer break into separate methods and return early:
def do_something
return 'foo' if ...
return 'bar' if ...
'baz'
end
discussions = discussions... appears many times, but looks weird. Can you use return discussions... instead?
Why does the constant STATUSES appear at the end? Usually constants appear at the top of the model.
Be sure to write all your tests before refactoring.
To respond to the comment about return 'foo' if ...:
Consider:
def evaluate_something
if a==1
return 'foo'
elsif b==2
return 'bar'
else
return 'baz'
end
end
I suggest refactoring this to:
def evaluate_something
return 'foo' if a==1
return 'bar' if b==2
'baz'
end
Perhaps you can refactor some of your if..then..else..if statements.
Recommended book: Clean Code

Rails 3 multiple parameter filtering using scopes

Trying to do a basic filter in rails 3 using the url params. I'd like to have a white list of params that can be filtered by, and return all the items that match. I've set up some scopes (with many more to come):
# in the model:
scope :budget_min, lambda {|min| where("budget > ?", min)}
scope :budget_max, lambda {|max| where("budget < ?", max)}
...but what's the best way to use some, none, or all of these scopes based on the present params[]? I've gotten this far, but it doesn't extend to multiple options. Looking for a sort of "chain if present" type operation.
#jobs = Job.all
#jobs = Job.budget_min(params[:budget_min]) if params[:budget_min]
I think you are close. Something like this won't extend to multiple options?
query = Job.scoped
query = query.budget_min(params[:budget_min]) if params[:budget_min]
query = query.budget_max(params[:budget_max]) if params[:budget_max]
#jobs = query.all
Generally, I'd prefer hand-made solutions but, for this kind of problem, a code base could become a mess very quickly. So I would go for a gem like meta_search.
One way would be to put your conditionals into the scopes:
scope :budget_max, lambda { |max| where("budget < ?", max) unless max.nil? }
That would still become rather cumbersome since you'd end up with:
Job.budget_min(params[:budget_min]).budget_max(params[:budget_max]) ...
A slightly different approach would be using something like the following inside your model (based on code from here:
class << self
def search(q)
whitelisted_params = {
:budget_max => "budget > ?",
:budget_min => "budget < ?"
}
whitelisted_params.keys.inject(scoped) do |combined_scope, param|
if q[param].nil?
combined_scope
else
combined_scope.where(whitelisted_params[param], q[param])
end
end
end
end
You can then use that method as follows and it should use the whitelisted filters if they're present in params:
MyModel.search(params)

Resources