Rails: ActiveRecord where vs merge - ruby-on-rails

First, I am getting the review statuses between particular dates.
date_range = Date.parse(#from_date).beginning_of_day..Date.parse(#to_date).end_of_day
#review_statuses = ReviewStatus.where(updated_at: date_range)
Next, I need to apply an 'AND' condition.
#review_cycle = params[:review_cycle]
if #review_cycle.present?
#review_statuses = #review_statuses.merge(
ReviewStatus.where(evidence_cycle: #review_cycle)
.or(ReviewStatus.where(roc_cycle: #review_cycle)))
end
Now for the below should I apply a 'where' or 'merge'.
#status = params[:status]
#review_statuses.where(evidence_status: :pass, roc_status: :pass) if #status == 'pass'
Can someone explain, when should we use merge instead of where?

You generally want to use where except in special circumstances -- most commonly, to apply conditions to a secondary (joined) table in the query. This is becase
it's shorter / clearer / more idiomatic, and
merge has tricky edge cases: it mostly combines the two queries, but there are situations where one side's value will just override the other.
Given that, even your existing condition doesn't need merge:
# Unchanged
date_range = Date.parse(#from_date).beginning_of_day..Date.parse(#to_date).end_of_day
#review_statuses = ReviewStatus.where(updated_at: date_range)
# direct #where+#or over #merge
#review_cycle = params[:review_cycle]
if #review_cycle.present?
#review_statuses = #review_statuses.where(evidence_cycle: #review_cycle).or(
#review_statuses.where(roc_cycle: #review_cycle))
end
# more #where
#status = params[:status]
#review_statuses = #review_statuses.where(evidence_status: :pass, roc_status: :pass) if #status == 'pass'

Related

Best way to conditionally chain scopes

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

Add multiple filters in Spree Commerce Rails

We need to implement custom filters for categories in spree ecommerce in latest version as seen here https://github.com/spree/spree .
We need to do it in a dynamic way because we have about 100 filters or more to make. The ideal solution would be to show all available filters in admin area and admin can activate/deactivate them for each category.
Current Scenario:
We know how to make a new filter and apply it. But it takes about four methods per filter as shown in the product_filter.rb file linked below.
Some links we have found useful:
https://gist.github.com/maxivak/cc73b88699c9c6b45a95
https://github.com/radar/spree-core/blob/master/lib/spree/product_filters.rb
Here is some code that allows you to filter by multiple properties. It is not ideal (no proper validation etc) but I guess it is better than doing multiple "in" subqueries.
def add_search_scopes(base_scope)
joins = nil
conditions = nil
product_property_alias = nil
i = 1
search.each do |name, scope_attribute|
scope_name = name.to_sym
# If method is defined in product_filters
if base_scope.respond_to?(:search_scopes) && base_scope.search_scopes.include?(scope_name.to_sym)
base_scope = base_scope.send(scope_name, *scope_attribute)
else
next if scope_attribute.first.empty?
# Find property by name
property_name = name.gsub('_any', '').gsub('selective_', '')
property = Spree::Property.find_by_name(property_name)
next unless property
# Table joins
joins = product if joins.nil?
product_property_alias = product_property.alias("filter_product_property_#{i}")
joins = joins.join(product_property_alias).on(product[:id].eq(product_property_alias[:product_id]))
i += 1
# Conditions
condition = product_property_alias[:property_id].eq(property.id)
.and(product_property_alias[:value].eq(scope_attribute))
conditions = conditions.nil? ? condition : conditions.and(condition)
end
end if search.is_a?(Hash)
joins ? base_scope.joins(joins.join_sources).where(conditions) : base_scope
end
def prepare(params)
super
#properties[:product] = Spree::Product.arel_table
#properties[:product_property] = Spree::ProductProperty.arel_table
end

Rails model query with many optional params

A user wants to search by an attribute and/or order the results. Here are some example requests
/posts?order=DESC&title=cooking
/posts?order=ASC
/posts?title=cooking
How can I conditionally chain such options to form a query?
So far I have a very ugly method that will quickly become difficult to maintain.
def index
common = Hash.new
common["user_id"] = current_user.id
if params[:order] && params[:title]
#vacancies = Post.where(common)
.where("LOWER(title) LIKE ?", params[:title])
.order("title #{params[:order]}")
elsif params[:order] && !params[:title]
#vacancies = Post.where(common)
.order("title #{params[:order]}")
elsif params[:title] && !params[:order]
#vacancies = Post.where(common)
.where("LOWER(title) LIKE ?", params[:title])
end
end
Remember that query methods like where and order are meant to be chained. What you want to do is start with a base query (like Post.where(common), which you use in all cases) and then conditionally chain other methods:
def index
common = Hash.new
common["user_id"] = current_user.id
#vacancies = Post.where(common)
if params[:order]
#vacancies = #vacancies.order(title: params[:order].to_sym)
end
if params[:title]
#vacancies = #vacancies.where("LOWER(title) LIKE ?", params[:title])
end
end
P.S. Your original code had .order("title #{params[:order]}"). This is very dangerous, since it opens you up to SQL injection attacks. As a rule of thumb never use string concatenation (#{...}) with a value you get from the end user when you're going to pass the result to the database. Accordingly, I've changed it to .order(title: params[:order]). Rails will use this hash to construct a secure query so you don't have to worry about injection attacks.
You can read more about SQL injection attacks in Rails in the official Ruby on Rails Security Guide.

Retrieving only unique records with multiple requests

I have this "heavy_rotation" filter I'm working on. Basically it grabs tracks from our database based on certain parameters (a mixture of listens_count, staff_pick, purchase_count, to name a few)
An xhr request is made to the filter_tracks controller action. In there I have a flag to check if it's "heavy_rotation". I will likely move this to the model (cos this controller is getting fat)... Anyway, how can I ensure (in a efficient way) to not have it pull the same records? I've considered an offset, but than I have to keep track of the offset for every query. Or maybe store track.id's to compare against for each query? Any ideas? I'm having trouble thinking of an elegant way to do this.
Maybe it should be noted that a limit of 14 is set via Javascript, and when a user hits "view more" to paginate, it sends another request to filter_tracks.
Any help appreciated! Thanks!
def filter_tracks
params[:limit] ||= 50
params[:offset] ||= 0
params[:order] ||= 'heavy_rotation'
# heavy rotation filter flag
heavy_rotation ||= (params[:order] == 'heavy_rotation')
#result_offset = params[:offset]
#tracks = Track.ready.with_artist
params[:order] = "tracks.#{params[:order]}" unless heavy_rotation
if params[:order]
order = params[:order]
order.match(/artist.*/){|m|
params[:order] = params[:order].sub /tracks\./, ''
}
order.match(/title.*/){|m|
params[:order] = params[:order].sub /tracks.(title)(.*)/i, 'LOWER(\1)\2'
}
end
searched = params[:q] && params[:q][:search].present?
#tracks = parse_params(params[:q], #tracks)
#tracks = #tracks.offset(params[:offset])
#result_count = #tracks.count
#tracks = #tracks.order(params[:order], 'tracks.updated_at DESC').limit(params[:limit]) unless heavy_rotation
# structure heavy rotation results
if heavy_rotation
puts "*" * 300
week_ago = Time.now - 7.days
two_weeks_ago = Time.now - 14.days
three_months_ago = Time.now - 3.months
# mix in top licensed tracks within last 3 months
t = Track.top_licensed
tracks_top_licensed = t.where(
"tracks.updated_at >= :top",
top: three_months_ago).limit(5)
# mix top listened to tracks within last two weeks
tracks_top_listens = #tracks.order('tracks.listens_count DESC').where(
"tracks.updated_at >= :top",
top: two_weeks_ago)
.limit(3)
# mix top downloaded tracks within last two weeks
tracks_top_downloaded = #tracks.order("tracks.downloads_count DESC").where(
"tracks.updated_at >= :top",
top: two_weeks_ago)
.limit(2)
# mix in 25% of staff picks added within 3 months
tracks_staff_picks = Track.ready.staff_picks.
includes(:artist).order("tracks.created_at DESC").where(
"tracks.updated_at >= :top",
top: three_months_ago)
.limit(4)
#tracks = tracks_top_licensed + tracks_top_listens + tracks_top_downloaded + tracks_staff_picks
end
render partial: "shared/results"
end
I think seeking an "elegant" solution is going to yield many diverse opinions, so I'll offer one approach and my reasoning. In my design decision, I feel that in this case it's optimal and elegant to enforce uniqueness on query intersections by filtering the returned record objects instead of trying to restrict the query to only yield unique results. As for getting contiguous results for pagination, on the other hand, I would store offsets from each query and use it as the starting point for the next query using instance variables or sessions, depending on how the data needs to be persisted.
Here's a gist to my refactored version of your code with a solution implemented and comments explaining why I chose to use certain logic or data structures: https://gist.github.com/femmestem/2b539abe92e9813c02da
#filter_tracks holds a hash map #tracks_offset which the other methods can access and update; each of the query methods holds the responsibility of adding its own offset key to #tracks_offset.
#filter_tracks also holds a collection of track id's for tracks that already appear in the results.
If you need persistence, make #tracks_offset and #track_ids sessions/cookies instead of instance variables. The logic should be the same. If you use sessions to store the offsets and id's from results, remember to clear them when your user is done interacting with this feature.
See below. Note, I refactored your #filter_tracks method to separate the responsibilities into 9 different methods: #filter_tracks, #heavy_rotation, #order_by_params, #heavy_rotation?, #validate_and_return_top_results, and #tracks_top_licensed... #tracks_top_<whatever>. This will make my notes easier to follow and your code more maintainable.
def filter_tracks
# Does this need to be so high when JavaScript limits display to 14?
#limit ||= 50
#tracks_offset ||= {}
#tracks_offset[:default] ||= 0
#result_track_ids ||= []
#order ||= params[:order] || 'heavy_rotation'
tracks = Track.ready.with_artist
tracks = parse_params(params[:q], tracks)
#result_count = tracks.count
# Checks for heavy_rotation filter flag
if heavy_rotation? #order
#tracks = heavy_rotation
else
#tracks = order_by_params
end
render partial: "shared/results"
end
All #heavy_rotation does is call the various query methods. This makes it easy to add, modify, or delete any one of the query methods as criteria changes without affecting any other method.
def heavy_rotation
week_ago = Time.now - 7.days
two_weeks_ago = Time.now - 14.days
three_months_ago = Time.now - 3.months
tracks_top_licensed(date_range: three_months_ago, max_results: 5) +
tracks_top_listens(date_range: two_weeks_ago, max_results: 3) +
tracks_top_downloaded(date_range: two_weeks_ago, max_results: 2) +
tracks_staff_picks(date_range: three_months_ago, max_results: 4)
end
Here's what one of the query methods looks like. They're all basically the same, but with custom SQL/ORM queries. You'll notice that I'm not setting the :limit parameter to the number of results that I want the query method to return. This would create a problem if one of the records returned is duplicated by another query method, like if the same track was returned by staff_picks and top_downloaded. Then I would have to make an additional query to get another record. That's not a wrong decision, just one I didn't decide to do.
def tracks_top_licensed(args = {})
args = #default.merge args
max = args[:max_results]
date_range = args[:date_range]
# Adds own offset key to #filter_tracks hash map => #tracks_offset
#tracks_offset[:top_licensed] ||= 0
unfiltered_results = Track.top_licensed
.where("tracks.updated_at >= :date_range", date_range: date_range)
.limit(#limit)
.offset(#tracks_offset[:top_licensed])
top_tracks = validate_and_return_top_results(unfiltered_results, max)
# Add offset of your most recent query to the cumulative offset
# so triggering 'view more'/pagination returns contiguous results
#tracks_offset[:top_licensed] += top_tracks[:offset]
top_tracks[:top_results]
end
In each query method, I'm cleaning the record objects through a custom method #validate_and_return_top_results. My validator checks through the record objects for duplicates against the #track_ids collection in its ancestor method #filter_tracks. It then returns the number of records specified by its caller.
def validate_and_return_top_results(collection, max = 1)
top_results = []
i = 0 # offset incrementer
until top_results.count >= max do
# Checks if track has already appeared in the results
unless #result_track_ids.include? collection[i].id
# this will be returned to the caller
top_results << collection[i]
# this is the point of reference to validate your query method results
#result_track_ids << collection[i].id
end
i += 1
end
{ top_results: top_results, offset: i }
end

Ruby: DRY out creation of Active Record Relation object

I am building up an Active Record Relation object in steps, allowing a user to filter a list of car parts by make and model of car:
parts = Part.where(:listed => (start_date..end_date))
unless filters[:make_id].nil? || filters[:make_id] == 0
parts = parts.joins(:car).
where("cars.make_id = ?", filters[:make_id] )
end
unless filters[:model_id].nil? || filters[:model_id] == 0
parts = parts.joins(:car).
where("cars.model_id = ?", filters[:model_id] )
end
etc...
This is repetitive, and I would like to pull this out into a method that takes 'make_id' and 'model_id' as parameters. One important feature to retain is that if a filter is nil (e.g. the user doesn't specify the model) then parts for all models are returned.
I can't figure out how to tackle this and I'm not sure what to Google. Can you help?
parts = Part.where(:listed => (start_date..end_date))
[:make_id, :model_id].each do |k|
filters[k]
.tap{|f| parts = parts.joins(:car).where("cars.#{k} = ?", f) unless f.to_i.zero?}
end

Resources