Best way to order data by association value in rails - sql

Trying to order all rows of a model for an ordering filter with react on the front, I have encountered this problem.
For example if I have "rooms" and each reservation having many products, having each product different prices, I came up with this way of ordering the rooms by the their respective lowest valued product or the highest:
scope :high_price, lambda { joins(:products).group('rooms.id').order('max(products.week_price) DESC') }
scope :low_price, lambda { joins(:products).group('rooms.id').order('min(products.week_price) ASC') }
The problem comes when, if I save this into an instance variable:
#ordered_rooms = Room.low_price
And then I try to manipulate given instance, I will run into this issue:
ActiveRecord::StatementInvalid (PG::GroupingError: ERROR: column "products.id" must appear in the GROUP BY clause or be used in an aggregate function
I found some explanation of the problem here, but then my doubt would be how to do this queries so this error does not come up?
I found that, if the metric I was looking for, would be the number of products by room, it would be easier to look up with:
scope :most_products, lambda { order('products_count DESC') }
adding this relation into product:
belongs_to :room, required: false, counter_cache: true
would it be possible to define this kind of cache for other metrics, or how should I go about these queries?

Related

Rails: Class method scoping on the properties of an associated model

This is a somewhat more complicated version of the question I asked previously.
Background:
So what I need is to display a list of articles. An article belongs to a media outlet. A media is located in a particular country and publishes articles in a particular language. So the data structure is as follows:
Article belongs to Media; Media has many Articles
Media belongs to a Country; Country has many Media
Media belongs to a Language; Language has many Media
Now, if I wanted to filter articles by media, I could use the following class method (I prefer class methods over scopes, because I am passing a parameter and am using a conditional statement inside the method):
def self.filter_by_media(parameter)
if parameter == "all"
all
else
where(media_id: parameter)
end
end
Question:
How to write a class method that would filter Articles based by properties of its associated model, the Media? For example, I want to get a list of articles published by media located a certain counrty or in several countries (there is also a default country when the user does not make any choice). Here’s what I tried:
# parameter can be either string 'default' or an array of id’s
def self.filter_by_country(parameter)
if parameter == "default"
joins(:media).where(media: [country_id: 1])
else
joins(:media).where(media: [country_id: parameter])
end
end
But that doesn’t work, and I am not conversant enough with SQL to figure out how to make this work. Could you please help?
Update:
I’m trying out #carlosramireziii's suggestion. I changed arrays into hashes (don't know what possessed me to use arrays in the first place), but I’m getting the following error in the Rails console (to avoid confusion, in my database, media is called agency):
def self.filter_by_country(parameter)
if parameter == "default"
joins(:agency).where(agency: {country_id: 1})
else
joins(:agency).where(agency: {country_id: parameter})
end
end
in Rails console:
> Article.filter_by_country('default')
=> Article Load (1.9ms) SELECT "articles".* FROM "articles" INNER JOIN "agencies" ON "agencies"."id" = "articles"."agency_id" WHERE "agency"."country_id" = 1
PG::UndefinedTable: ERROR: missing FROM-clause entry for table "agency"
LINE 1: ...ON "agencies"."id" = "articles"."agency_id" WHERE "agency"."...
^
: SELECT "articles".* FROM "articles" INNER JOIN "agencies" ON "agencies"."id" = "articles"."agency_id" WHERE "agency"."country_id" = 1
Update 2
My mistake in the Update section above is that I did not pluralize agency in the where clause. The part where(agency: {country_id: 1}) should have read where(agencies: {country_id: 1}). The pluralized word agencies here refers to the name of the table that is being joined.
You are very close, you just need to use a nested hash instead of an array.
Try this
def self.filter_by_country(parameter)
if parameter == "default"
joins(:media).where(media: { country_id: 1 })
else
joins(:media).where(media: { country_id: parameter })
end
end

Rails ActiveRecord scope with multiple conditions

I have a Rails app where I have a Unit model and Status model. Status has_many units and Unit belongs_to Status.
I wrote a scope on Unit to find all of the Units with a specific Status, "In Service" like so:
scope :in_service, lambda { where(status_id: Status.find_by_unit_status("In Service").id)}
This allows me to call Unit.in_service.count to get a count of all Units that are In Service.
But what I really want to do is write a scope that will allow me to scope out all Units with a Status of In Service, At Post, and At Station to get an accurate view of Units since these other two Statuses are considering the Unit to be available.
I'm not sure how I would write something like this. Where the scope contains multiple conditions or data fields.
Can anyone lend a hand?
Update
I tried writing a class method called available like this:
def self.available
Unit.where(status_id: Status.find_by_unit_status("In Service").id)
.where(status_id: Status.find_by_unit_status("At Post").id)
.where(status_id: Status.find_by_unit_status("At Station").id)
end
I'm not sure if this method even is what I'm looking for since I want to find Units with each of these statuses. I think what I just wrote might be constraining the method to where the Unit must have all of these statuses.
You've got a couple things going on here. First if you want to find all units where the status is one of many, you'll need to pass that as an array like so:
scope :in_service, lambda { where(status_id: Status.where(unit_status: ["In Service", "At Post", "At Station"]).map(&:id))}
Second, in your updated method example, you're chaining a bunch of where clauses together, which will result in each one joined with an AND.

Matching nested model association attribute with includes

Suppose I have the following models:
class Post < ActiveRecord::Base
has_many :authors
class Author < ActiveRecord::Base
belongs_to :post
And suppose the Author model has an attribute, name.
I want to search for all posts with a given author "alice", by that author's name. Say there is another author "bob" who co-authored a post with alice.
If I search for the first result using includes and where:
post = Post.includes(:authors).where("authors.name" => "alice").first
You'll see that the post only has one author now, even if in fact there are more:
post.authors #=> [#<Author id: 1, name: "alice", ...>]
post.reload
post.authors #=> [#<Author id: 1, name: "alice", ...>, #<Author id: 2, name: "bob", ...>]
The problem seems to be the combination of includes and where, which limits the scope correctly to the desired post, but at the same time hides all associations except for the one that is matched.
I want to end up with an ActiveRecord::Relation for chaining, so the reload solution above is not really satisfactory. Replacing includes by joins solves this, but does not eager load the associations:
Post.joins(:authors).where("authors.name" => "alice").first.authors
#=> [#<Author id: 1, name: "alice", ...>, #<Author id: 2, name: "bob", ...>]
Post.joins(:authors).where("authors.name" => "alice").first.authors.loaded?
#=> false
Any suggestions? Thanks in advance, I've been banging my head over this problem for a while.
I see what you're doing as expected behaviour, at least that's how SQL works... You're restricting the join on authors to where authors.id = 1, so why would it load any others? ActiveRecord just takes the rows that the database returned, it has no way of knowing if there are others, without doing another query based on the posts.id.
Here's one possible solution with a subquery, this will work as a chainable relation, and executes in one query:
relation = Post.find_by_id(id: Author.where(id:1).select(:post_id))
If you add the includes, you will see the queries happen one of two ways:
relation = relation.includes(:authors)
relation.first
# 1. Post Load SELECT DISTINCT `posts`.`id`...
# 2. SQL SELECT `posts`.`id` AS t0_r0, `posts`.`title` AS t0_r1, ...
relation.all.first
# 1. SQL SELECT `posts`.`id` AS t0_r0, `posts`.`title` AS t0_r1, ...
So depending on the scenario, ActiveRecord decides whether to look up the id with a simpler query before loading all the associated authors. Sometimes it makes more sense to run the query in 2 steps.
Coming back to this question after a long long time, I realized there is a better way to do this. The key is to do not one but two joins, one with includes and one with Arel using a table alias:
posts = Post.arel_table
authors = Author.arel_table.alias("matching_authors")
join = posts.join(authors, Arel::Nodes::InnerJoin).
on(authors[:post_id].eq(posts[:id])).join_sources
post = Post.includes(:authors).joins(join).
where(matching_authors: { name: "Alice" }).first
The SQL for this query is quite long since it has includes, but the key point is that it has not one but two joins, one (from includes) using a LEFT OUTER JOIN on the alias posts_authors, the other (from the Arel join) using an INNER JOIN on the alias matching_authors. The WHERE only applies to the latter alias, so results on the association in the returned results are not limited by this condition.
I ran into the same issue (which I describe as: where clause filters the associated model, rather than the primary model, when includes is used to prevent N+1 queries).
After flailing around trying various solutions, I found that using preload in conjunction with joins solves this for me. The Rails documentation is not super useful here. But apparently preload will explicitly use two separate queries, one to filter/select the primary model, and a second query to load the associated models. This blog post also has some insights that helped lead me to the solution.
Applying this to your models would be something like:
post = Post.preload(:authors).joins(:authors).where("authors.name" => "alice").first
I suspect that under the covers this is doing the same thing as your accepted answer, but at a nicer level of abstraction.
I wish the Rails docs were more explicit about how to do this. It's subtle enough that I wrote a bunch of tests around this precise situation in my code base.
Actually, it's because this code:
post = Post.includes(:authors).where("authors.name" => "alice").first
returns the first matched record because of the ".first". I think if you did this:
post = Post.includes(:authors).where("authors.name" => "alice")
you would get back all posts with "alice" and her other co-authors if I understand what you're asking correctly.

rails scope filtering

Hey guys, I have the following scope:
scope :expires_within, lambda
{|time| where("created_at BETWEEN ? AND ?", 30.days.ago,
time.from_now - 30.days)}
It's not all that important, it works.
This simply gives me all of the objects in my database which were created within a certain time frame. What I want to do is filter this scope such that it removes some of the objects.
The above scope is on a model named Post. I have another model named Lock which "belongs to" a Post, and each Post "has many" Locks. So this means that there is a foreign key on each lock with the id of its corresponding Post.
What I want to accomplish is the following: I want to filter out the posts from the above scope which do not have any locks. So from an abstract/high-level view: I want to get the posts returned from the above scope and remove any which have any associated locks (even if just one).
Is this possible? Would I have to use some form of compound query, using something like except? I'd appreciate any help.
I currently have something that works, but I have a nagging feeling that it isn't very efficient, perhaps it can be done on the database by modifying the above scope and be more efficient:
Post.expires_within(1.day) - Lock.all.collect { |lock| lock.post }
So this basically gets the collection of posts, then it fetches each of the locks' posts and dumps them all into an array which is then subtracted from the original set of posts.
Someone who has already experienced this problem was kind enough to help me out on IRC (Radar), and pointed me to this answer. Now my new scope is the following:
scope :not_locked, lambda { joins("LEFT JOIN locks on
(posts.id = locks.post_id)").where("locks.post_id IS NULL") }
scope :expires_within, lambda {|time| where("posts.created_at BETWEEN ? AND ?",
30.days.ago, time.from_now - 30.days).not_locked }
And it works very well. Hope that helps anyone else out there with the same problem.
With plain ActiveRelation, string-based LEFT JOINs are unavoidable; however, you can greatly simplify the BETWEEN calculations using the Ruby Range class:
scope :expires_within, lambda { |time|
where(:created_at => 30.days.ago..(time.from_now - 30.days)) }
You should be do it with a subquery, something like...
scope :without_locks, :conditions => "not exists(select * from locks where posts.id = locks.post_id)"

Rails3: Cascading Select Writer's Block

I have a big, flat table:
id
product_id
attribute1
attribute2
attribute3
attribute4
Here is how I want users to get to products:
See a list of unique values for attribute1.
Clicking one of those gets you a list of unique values for attribute2.
Clicking one of those gets you a list of unique values for attribute3.
Clicking one of those gets you a list of unique values for attribute4.
Clicking one of those shows you the relevant products.
I have been coding Rails for about 4 years now. I just can't unthink my current approach to this problem.
I have major writer's block. Seems like such an easy problem. But I either code it with 4 different "step" methods in my controller, or I try to write one "search" method that attempts to divine the last level you selected, and all the previous values that you selected.
Both are major YUCK and I keep deleting my work.
What is the most elegant way to do this?
Here is a solution that may be an option. Just off the top of my head and not tested (so there is probably a bit more elegant solution). You could use chained scopes in your model:
class Product < ActiveRecord::Base
scope :with_capacity, lambda { |*args| args.first.nil? ? nil : where(:capacity=>args.first) }
scope :with_weight, lambda { |*args| args.first.nil? ? nil : where(:weight=>args.first) }
scope :with_color, lambda { |*args| args.first.nil? ? nil : where(:color=>args.first) }
scope :with_manufacturer, lambda { |*args| args.first.nil? ? nil : where(:manufacturer=>args.first) }
self.available_attributes(products,attribute)
products.collect{|product| product.send(attribute)}.uniq
end
end
The code above will give you a scope for each attribute. If you pass a parameter to the scope, then it will give you the products with that attribute value. If the argument is nil, then the scope will return the full set (I think ;-). You could keep track of the attributes they are drilling down in in the session with 2 variables (page_attribute and page_attribute_value) in your controller. Then you call the entire chain to get your list of products (if you want to use them on the page). Next you can get the attribute values by passing in the set of products and the attribute name to Product.available_attributes. Note that this method (Product.available_attributes) is a total hack and would be inefficient for a large set of data, so you may want to make this another scope and use :select=>"DISTINCT(your_attribute)" or something more database efficient instead of iterating thru the full set of products as I did in the hack method.
class ProductsController < ApplicationController
def show
session[params[:page_attribute].to_sym] = params[:page_attribute_value]
#products = Product.all.with_capacity(session[:capacity]).with_weight(session[:weight]).with_color(session[:color]).with_manufacturer(session[:manufacturer])
#attr_values = Product.available_attributes(#products,params[:page_attribute])
end
end
Again, I want to warn you that I did not test this code, so its totally possible that some of the syntax is incorrect, but hopefully this will give you a starting point. Holla if you have any questions about my (psuedo) code.