Better solution for "one, other or both" cases - ruby-on-rails

I was checking some code, and something similar to the following showed up:
def between_dates(date_1, date_2)
if date_1 && date_2
conditions "created_at >= date_1 AND created_at <= date_2"
elseif date_1
conditions "created_at >= date_1"
elseif date_2
conditions "created_at <= date_2"
end
end
It looked the kind of code that could be improved, but I couldn't find a more elegant solution for such a trivial and common conditional statement.
I'm looking for a better answer for this problem when we must return a value for one, other or both.

Rails lets you build a query dynamically. Here's an example using scopes and a class method. Since scopes always return an ActiveRecord::Relation object (even if the block returns nil), they are chainable:
class Event < ApplicationRecord
scope :created_before, -> (date) { where('created_at <= ?', date) if date }
scope :created_after, -> (date) { where('created_at >= ?', date) if date }
def self.created_between(date_1, date_2)
created_after(date_1).created_before(date_2)
end
end
Example usage:
Event.created_between(nil, Date.today)
# SELECT `events`.* FROM `events` WHERE (created_at <= '2018-05-15')
Event.created_between(Date.yesterday, nil)
# SELECT `events`.* FROM `events` WHERE (created_at >= '2018-05-14')
Event.created_between(Date.yesterday, Date.today)
# SELECT `events`.* FROM `events` WHERE (created_at >= '2018-05-14') AND (created_at <= '2018-05-15')

I'd use something like this:
def between_dates(date_1, date_2)
parts = []
if date_1
parts << "created_at >= date_1"
end
if date_2
parts << "created_at <= date_2"
end
full = parts.join(' AND ')
conditions(full)
end
This can be further prettified in many ways, but you get the idea.

def between_dates(date_1, date_2)
date_conditions = []
date_conditions << 'created_at >= date_1' if date_1
date_conditions << 'created_at <= date_2' if date_2
conditions date_conditions.join(' AND ') unless date_conditions.empty?
end

I am not sure if this is more elegant, but I always do reduce everything to avoid typos:
[[date_1, '>='], [date_2, '<=']].
select(&:first).
map { |date, sign| "created_at #{sign} #{date}" }.
join(' AND ')

Related

Rails - Order index by complex sort

How do I order my index results by featured_end_date >= Time.now() in :asc order and then have the rest of the results sort by by publish_at: :desc.
Currently I have BlogPost.order(featured_end_date: :asc, publish_at: :desc). I am missing that >= Time.now() comparison.
I assume scopes might need to be used, but I am not sure how to achieve this.
BlogPost model
scope :featuredfuture, -> { where("featured_end_date >= ?", Time.now()).order(featured_end_date: :asc) }
scope :other, -> { where("featured_end_date < ? or featured_end_date is null", Time.now()).order(publish_at: :desc) }
Controller
#blogposts = BlogPost.featuredfuture + BlogPost.other
You need to specify your collection before ordering. You have to use where.
BlogPost.where('featured_end_date >= ?', Time.now).order(featured_end_date: :asc, publish_at: :desc)

Querying for X when attr == A and Y when attr == B in Rails

This is the query I'm trying to run. Screenshot.
#colleges = College.all
#colleges = #colleges.where(category: #university_type) unless #university_type.blank? or #university_type.all? &:blank?
#colleges = #colleges.where("us_news_ranking <= ?", #rank_low) unless #rank_low.blank?
#colleges = #colleges.where("(sat_math_25+sat_math_75+sat_reading_25+sat_reading_75)/2 >= ?", #sat_low) unless #sat_low.blank?
#colleges = #colleges.where("(sat_math_25+sat_math_75+sat_reading_25+sat_reading_75)/2 <= ?", #sat_high) unless #sat_high.blank?
#colleges = #colleges.where("(act_composite_25+act_composite_75)/2 >= ?", #act_low) unless #act_low.blank?
#colleges = #colleges.where("(act_composite_25+act_composite_75)/2 <= ?", #act_high) unless #act_high.blank?
if !#cost_low.blank?
if #in_state.blank?
#colleges = #colleges.where("out_of_state_tuition+room_and_board >= ?", #cost_low)
#colleges = #colleges.where("out_of_state_tuition+room_and_board <= ?", #cost_high)
else
#colleges = #colleges.where(state: #in_state).where("in_state_tuition+room_and_board >= ? AND in_state_tuition+room_and_board <= ?", #cost_low, #cost_high)
#colleges = #colleges.where("state != ? AND out_of_state_tuition+room_and_board >= ? AND out_of_state_tuition+room_and_board <= ?", #in_state, #cost_low, #cost_high)
end
end
I've tested it, and the problem is with the else statement. If I comment out one of the lines in the else statement, the other one behaves as you'd expect. However, when I leave them both uncommented, it never returns any colleges.
I don't know what the problem is, but I figure that it has something to do with me querying for state = A in one line, and state = B in the other. Is that the problem? Why? If not, what is the problem?
College.rb
def self.get_college(university_type, rank_low, sat_low, sat_high, act_low, act_high, in_state, cost_low, cost_high)
colleges = College.all
colleges = colleges.where(category: university_type) unless university_type.blank? or university_type.all? &:blank?
colleges = colleges.where("us_news_ranking <= ?", rank_low) unless rank_low.blank?
colleges = colleges.where("(sat_math_25+sat_math_75+sat_reading_25+sat_reading_75)/2 >= ?", sat_low) unless sat_low.blank?
colleges = colleges.where("(sat_math_25+sat_math_75+sat_reading_25+sat_reading_75)/2 <= ?", sat_high) unless sat_high.blank?
colleges = colleges.where("(act_composite_25+act_composite_75)/2 >= ?", act_low) unless act_low.blank?
colleges = colleges.where("(act_composite_25+act_composite_75)/2 <= ?", act_high) unless act_high.blank?
select_fields = sanitize_sql_array( [ <<-ENDSQL, in_state ] )
*,
IF( colleges.state = ?,
in_state_tuition,
out_of_state_tuition
) AS user_tuition,
user_tuition + room_and_board AS total_cost
ENDSQL
colleges = colleges.select(select_fields).where("total_cost BETWEEN ? AND ?", cost_low, cost_high)
return colleges
end
tools_controller.rb
#colleges = College.get_college(#university_type, #rank_low, #sat_low, #sat_high, #act_low, #act_high, #in_state, #cost_low, #cost_high) if #searched
Update following #AdamZerner's comment:
Say the user selects a price range and a state. I want to return colleges in the price range, and to do that I see if tuition + room_and_board are in that range. But I want to calculate tuition using in_state_tuition if the user is in-state, and out_of_state_tuition if the user is out-of-state.
Ah, good question. As I mentioned, you need to know SQL, not just Rails. A nice, clean query for getting what you want looks like this:
SELECT *,
IF( colleges.state = #user_state,
in_state_tuition,
out_of_state_tuition
) AS user_tuition,
user_tuition + room_and_board AS total_cost
FROM colleges
WHERE total_cost BETWEEN #cost_low AND #cost_high
We use an IF() expression to decide whether to use in_state_tuition or out_of_state_tuition and give it a name, user_tuition. Then we take that and add it to room_and_board to get total_cost. Then in our WHERE we use BETWEEN, because it's much more concise and readable than A <= B AND B <= C.
Once we know what our SQL looks like, it's easy to translate into ActiveRecord methods.
# This will give you the `SELECT` part of the query above
select_fields = sanitize_sql_array( [ <<-ENDSQL, #in_state ] )
*,
IF( colleges.state = ?,
in_state_tuition,
out_of_state_tuition
) AS user_tuition,
user_tuition + room_and_board AS total_cost
ENDSQL
#colleges = College.select(select_fields)
.where("total_cost BETWEEN ? AND ?", #cost_low, #cost_high)
Note that sanitize_sql_array is a protected method of ActiveRecord::Base, so it will only work inside your model. But querying logic like this belongs in the model anyway. Your use case is perfect for Rails scopes:
class College < ActiveRecord::Base
scope :ranked_at_least, ->(rank=nil) {
return self if rank.nil?
where("us_news_ranking <= ?", rank)
}
scope :in_state_with_cost_between, ->(state_name, cost_low, cost_high) {
select_fields = sanitize_sql_array( [ <<-ENDSQL, state_name ] )
*,
IF( colleges.state = ?,
in_state_tuition,
out_of_state_tuition
) AS user_tuition,
user_tuition + room_and_board AS total_cost
ENDSQL
select(select_fields)
.where("total_cost BETWEEN ? AND ?", cost_low, cost_high)
}
scope :with_sat_composite_between, ->(score_low, score_high) {
# ...
}
# ...and so on...
end
This would allow you to make nice, clean queries like this:
College.in_state_with_cost_between("New York", 10_000, 50_000).
ranked_at_least(20).
with_sat_composite_between(1_200, 1_500)
...which seems a lot nicer to me.
Update 2 - Simpler and works with SQLite
I didn't realize that SQLite doesn't have IF(). I think the sanitize_sql_array bit overcomplicated things a bit, too, so let's simplify. The below SQLite query is equivalent to the one above (which works in MySQL and others):
SELECT colleges.*,
CASE WHEN colleges.state = 'New York'
THEN colleges.in_state_tuition
ELSE colleges.out_of_state_tuition
END AS user_tuition,
user_tuition + room_and_board AS total_cost
FROM colleges
WHERE total_cost BETWEEN 15000 AND 60000
The only difference is that we used CASE WHEN x THEN y ELSE z END instead of IF(x, y, z).
Now let's turn it into an ActiveRecord query:
# Always sanitize values you get from the user!
safe_state_name = ActiveRecord::Base.sanitize(#in_state)
select_sql = <<-ENDSQL
colleges.*,
CASE WHEN colleges.state = #{safe_state_name}
THEN colleges.in_state_tuition
ELSE colleges.out_of_state_tuition
END AS user_tuition,
user_tuition + room_and_board AS total_cost
ENDSQL
College.select(select_sql).
where("total_cost BETWEEN ? AND ?", #cost_low, #cost_high)
When we use the "?" replacement in where() Rails automatically sanitizes #cost_low and #cost_high for us, but we have to do it manually for the select(). Don't skip this step, though--it's very important!
We could also have written the query this way:
where_sql = <<-ENDSQL
( CASE WHEN colleges.state = ?
THEN colleges.in_state_tuition
ELSE colleges.out_of_state_tuition
END
) BETWEEN ? AND ?
ENDSQL
College.where(where_sql, #in_state, #cost_low, #cost_high)
...but I think using select() makes for cleaner queries, and also let you use a calculated value (e.g. user_tuition, total_cost) multiple times.
Scopes are a core part of Rails and learning to use them will help you write maintainable code. If you don't know scopes, you don't know rails. They're also really easy. In this case we could write a scope like this:
class College < ActiveRecord::Base
scope :for_state_with_cost_between, ->(state_name, cost_low, cost_high) {
safe_state_name = ActiveRecord::Base.sanitize(state_name)
select_sql = <<-ENDSQL
colleges.*,
CASE WHEN colleges.state = #{safe_state_name}
THEN colleges.in_state_tuition
ELSE colleges.out_of_state_tuition
END AS user_tuition,
user_tuition + room_and_board AS total_cost
ENDSQL
select(select_sql).
where("total_cost BETWEEN ? AND ?", cost_low, cost_high)
}
# ...
This is basically equivalent to defining a class method like this:
class College < ActiveRecord::Base
def self.for_state_with_cost_between(state_name, cost_low, cost_high)
safe_state_name = ActiveRecord::Base.sanitize(state_name)
select_sql = # ...
self.select(select_sql).where("total_cost BETWEEN ? AND ?", cost_low, cost_high)
end
# ...
In both cases you would use it like this:
College.for_state_with_cost_between("New York", 10_000, 50_000)
Using scopes your code could be written to be much cleaner and readable with less room for bugs. I didn't want to paste the whole thing here, but take a look at this gist (untested, of course).
Original answer
Let's break it down. First you do this:
#colleges = College.all
# ...let's pretend you didn't do anything here...
#colleges = #colleges.where( state: #in_state )
.where( "in_state_tuition + room_and_board >= ? AND
in_state_tuition + room_and_board <= ?",
#cost_low, #cost_high )
This creates a ActiveRecord::Relation and assigns it to #colleges. If you called #colleges.all now, it would generate and execute SQL like this (more or less):
SELECT * FROM colleges
WHERE state = #in_state AND
in_state_tuition + room_and_board >= #cost_low AND
in_state_tuition + room_and_board <= #cost_high
Next you do this:
#colleges = #colleges.where( "state != ? AND
out_of_state_tuition + room_and_board >= ? AND
out_of_state_tuition+room_and_board <= ?",
#in_state, #cost_low, #cost_high )
This takes the ActiveRecord::Relation object you created above and adds more WHERE conditions on to it. If you did #colleges.all now, it would generate and execute SQL like this:
SELECT * FROM colleges
WHERE ( state = #in_state AND
in_state_tuition + room_and_board >= #cost_low AND
in_state_tuition + room_and_board <= #cost_high
) AND
( state != #in_state AND
out_of_state_tuition + room_and_board >= #cost_low AND
out_of_state_tuition + room_and_board <= #cost_high
)
This makes it pretty obvious what the problem is. You have state = #in_state and state != #in_state in the same query. A state can't be "New York" and not "New York" at the same time, so your result is empty.
ActiveRecord gives you some nice abstractions and convenience methods for doing database queries, but in the end it's still very important to know what kind of SQL it's generating and what it means.

rails 3 sqlite pass array in query via placeholder

How can i give an array as a placeholder without sqlite seeing as 1 value but several values that are in the array
value = Array.new
value.push(broadcast_date_from)
value.push(broadcast_date_to)
puts value #["a", "2006-01-02 00:00", "2006-01-02 23:59"]
find(:all, :order => 'broadcast_date', :conditions => ['name LIKE ? and broadcast_date >= ? and broadcast_date <= ?', name, #value ])
But i get this error:
wrong number of bind variables (1 for 3) in: name LIKE ? and broadcast_date >= ? and broadcast_date <= ?
Is there anyway to make it see 3 values in the array and not 1.
You need to add the splat operator * before you call your array:
values = ['condition for name']
values.push(broadcast_date_from)
values.push(broadcast_date_to)
find(:all, :order => 'broadcast_date', :conditions => ['name LIKE ? and broadcast_date >= ? and broadcast_date <= ?', *values ])
Small article about the splat operator: http://theplana.wordpress.com/2007/03/03/ruby-idioms-the-splat-operator/
Improvement for you: use .where() instead of .find()
First, the excellent guide about it: http://guides.rubyonrails.org/active_record_querying.html#conditions
Then, a little example to show the benefits of the where:
class User < ActiveRecord::Base
def get_posts(options = {})
str_conditions = ['user_id = ?']
args_conditions = [self.id]
if options.has_key?(:active)
str_conditions << 'active = ?'
args_conditions << options[:active]
end
if options.has_key?(:after)
str_conditions << 'created_at >= ?'
args_conditions << options[:after]
end
if options.has_key?(:limit)
Post.find(:conditions => [str_conditions.join(' OR '), *args_conditions], :limit => options[:limit])
else
Post.find(:conditions => [str_conditions.join(' OR '), *args_conditions])
end
end
Different usages:
user = User.first
user.get_posts(:active => true, :after => Date.today, :limit => 10)
user.get_posts
The same method, but using the where method (very nice for chain-scoping):
def get_posts(options = {})
scope = self.posts
scope = scope.where(active: options[:active]) if options.has_key?(:active)
scope = scope.where('created_at >= ?', options[:after]) if options.has_key?(:after)
scope = scope.limit(options[:limit]) if options.has_key?(:limit)
return scope
end
Keep in mind that you can chain scope with the .where method:
User.where(active: true).where('created_at < ?', Date.today-1.weeks).includes(:posts).where(posts: { name: "Name of a specific post" })

Wrapping 'next' and 'previous' functions

In my Rails 4 app, I defined functions in my model than get the (nth) next or previous row in de database, wrapping around the entire database, so that Item.last.next will refer to Item.first:
def next(n=0)
following = Item.where("id > ?", self.id).order("id asc") + Item.where("id < ?", self.id).order("id asc")
following[n % following.length]
end
def prev(n=0)
n = n % Item.count-1
previous = Item.where("id < ?", self.id).order("id desc") + Item.where("id > ?", self.id).order("id desc")
previous[n % previous.length]
end
This results in three database queries per method call, and I've learned to keep database queries to a minimum, so I wonder if there is a way do get this result with only one query.
What you are looking for seems a bit high level. So let's prepare the basic API at first.
def next(n=1)
self.class.where('id > ?', id).limit(n).order('id ASC')
end
def previous(n=1)
self.class.where('id > ?', id).limit(n).order('id DESC')
end
Then higher level methods
def next_recycle(n=1)
klass = self.class
return klass.first if (n = 1 && self == klass.last)
next(n)
end
def previous_recycle(n=1)
klass = self.class
return klass.last if (n == 1 && self == klass.first)
previous(n)
end
You can pick methods according to needs.

Undefined method on class method

I am have an app where users post. I have a filter that will order and retrieve different posts based on a value passed through the params[] hash. I keep getting a no method error on a class method that clearly exists. Here is the query I am trying to run (posts_controller.rb):
def room
select_filter = params[:post_filter]
course_id = params[:id].to_i
#posts = Post.where_filter(select_filter, course_id).order_filter(select_filter).page(params[:page])
end
I am getting this error whenever 'select_filter' has a value of 3 :
undefined method `order_filter' for #<Array:0x007fa012d6eef8>
Here is my model (post.rb, remember whenever 'select_filter' is 3 I get an error):
def self.where_filter(select_filter, course_id_params)
case select_filter.to_i
when 1
where('course_id = ?', course_id_params)
when 2
where('course_id = ?', course_id_params)
when 3
where('course_id = ? AND created_at > ?', course_id_params.to_i, 48.hours.ago.utc.to_s(:db))
.reject! {|i| i.net_reputation <= 0 }
else
where('course_id = ?', course_id_params)
end
end
def self.order_filter(select_filter)
case select_filter.to_i
when 1
order('created_at DESC')
when 2
sort_by {|i| i.net_reputation}
when 3
sort_by {|i| i.net_reputation}
else
order('created_at DESC')
end
end
The strange thing is if I run this in the console, everything is fine. Like this:
Post.where('course_id = ? AND created_at > ?', 10, 48.hours.ago.utc.to_s(:db)).reject {|i| i.net_reputation <= 0 }.sort_by {|i| i.net_reputation}
Any and all input is appreciated.
It's because your "where_filter" method is changing it from an active record relation object to an array when it gets to:
.reject! {|i| i.net_reputation <= 0 }
Once this happens, you can no longer chain other query methods onto it. Why not just make the "i.net_reputation <= 0" part of the where query?
For some reason, when I collapsed the queries & sorts into one Class method, it worked. However, since Kiminari (which I am using for pagination) cannot pagniate arrays by default, I have to use the Kaminari.pageinate_array() method.
My posts_controller.rb:
def room
#course = Course.find(params[:id])
select_filter = params[:post_filter]
course_id = params[:id].to_i
query_posts = Post.select_input_filter(select_filter, course_id)
#posts = Kaminari.paginate_array(query_posts).page(params[:page])
end
My post.rb:
def self.select_input_filter(select_filter, course_id_params)
case select_filter.to_i
when 1
where('course_id = ?', course_id_params)
.order('created_at DESC')
when 2
where('course_id = ?', course_id_params)
.sort_by {|i| i.net_reputation}
.reverse
when 3
where('course_id = ? AND created_at > ?', course_id_params.to_i, 48.hours.ago.utc.to_s(:db))
.reject! {|i| i.net_reputation <= 0 }
.sort_by {|i| i.net_reputation}
.reverse
else
where('course_id = ?', course_id_params)
.order('created_at DESC')
end
end

Resources