rails order by association through another association? - ruby-on-rails

As a simple example, let's say a bookstore has books which have one author. The books has many sales through orders. Authors can have many books.
I am looking for a way to list the authors ordered by sales. Since the sales are associated with books, not authors, how can I accomplish this?
I would guess something like:
Author.order("sales.count").joins(:orders => :sales)
but that returns a column can't be found error.
I have been able to connect them by defining it in the Author model. The following displays the correct count for sales, but it does ping the database for each and every author... bad. I'd much rather eager load them, but I can't seem to get it to work properly since it will not list any authors who happen to have 0 sales if I remove the self.id and assign the join to #authors.
class Author < ActiveRecord::Base
def sales_count
Author.where(id: self.id).joins(:orders => :sales).count
end
end
And more specifically, how can I order them by the count result so I can list the most popular authors first?

Firstly, let's have all associations available on the Author class itself to keep the query code simple.
class Author < AR::Base
has_many :books
has_many :orders, :through => :books
has_many :sales, :through => :orders
end
The simplest approach would be for you to use group with count, which gets you a hash in the form {author-id: count}:
author_counts = Author.joins(:sales).group("authors.id").count
=> {1 => 3, 2 => 5, ... }
You can now sort your authors and lookup the count using the author_counts hash (authors with no sales will return nil):
<% Author.all.sort_by{|a| author_counts[a.id] || 0}.reverse.each do |author| %>
<%= author.name %>: <%= author_counts[author.id] || 0 %>
<% end %>
UPDATE
An alternative approach would be to use the ar_outer_joins gem that allows you get around the limitations of using includes to generate a LEFT JOIN:
authors = Author.outer_joins(:sales).
group(Author.column_names.map{|c| "authors.#{c}").
select("authors.*, COUNT(sales.id) as sales_count").
order("COUNT(sales.id) DESC")
Now your view can just look like this:
<% authors.each do |author| %>
<%= author.name %>: <%= author.sales_count %>
<% end %>
This example demonstrates how useful a LEFT JOIN can be where you can't (or specifically don't want to) eager load the other associations. I have no idea why outer_joins isn't included in ActiveRecord by default.

Related

Rails: Filter on has_many_through association where all checked associations exist

I am trying to build a checkbox filter, which further reduces the number of results with each extra checked box, where each checkbox represents a relation in a has_many through association.
I have an app with the following models:
Hospital
HospitalFeatures
Features
Here are the associations:
class Hospital < ActiveRecord::Base
has_many :hospital_features
has_many :features, through: :hospital_features
end
class HospitalFeature < ActiveRecord::Base
belongs_to :hospital
belongs_to :feature
end
class Feature < ActiveRecord::Base
has_many :hospital_features
has_many :hospitals, through: :hospital_features
end
I have a check-box form that lists all of the features available.
<%= label_tag("helipad", "Helipad") %>
<%= check_box_tag("features[]", "helipad" %>
<%= label_tag("telesurgery", "Telesurgery") %>
<%= check_box_tag("features[]", "telesurgery" %>
<%= label_tag("emergency room", "Emergency Room") %>
<%= check_box_tag("features[]", "emergency room" %>
I am trying to filter like you would on a shopping site, where each checked box further filters to only the hospitals with the ALL checked features.
The query I'm using now:
hospitals = Hospital.joins(:features).where(features: {name: features} )
does the opposite. Each extra box checked increases the number of results since it returns hospitals with ANY one of the checked features, rather than the hospitals with ALL of the checked features.
So if you check "helipad" and "telesurgery", it should return only hospitals with both "helipad" AND "telesurgery" rather than any hospital with either "helipad" OR "telesurgery".
Looked around and can't seem to find an obvious solution. I appreciate any help. Thanks in advance.
Maybe this is not the most beautiful solution, but it worked for me.
Hospital.joins(:features).where(features: {name: features}).group('hospitals.id').having("count(*) >= ?", features.size)
Got a solution. First joined hospitals to features then filtered where 'features.name in (?)' interpolating the features array into the query.
So this will return the same hospital for each feature that that hospital has. So if the list contains 4 features, and a hospital has all 4, that hospital will be returned 4 times. Likewise, if it only had 3 of the 4 features, it would return it 3 times.
So then you group by hospital id and add 'HAVING COUNT(*)' equal to the number of filtered features.
Then end result being:
hospitals = Hospital.joins(:features)
.where('features.name in (?)', searched_features)
.group("hospitals.id")
.having('COUNT(*) = ?', searched_features.length)
Hope this help someone else eventually. Let me know if anyone finds a more elegant way to do this.
I think there are a lot of options for doing this -- one that's worked well for me is Sunspot Solr filtering with facets. Railscasts explains it pretty well: http://railscasts.com/episodes/278-search-with-sunspot.
Using my Where Exists gem:
result = Hospital.all
features.each do |feature|
result = scope.where_exists(:features, name: feature)
end

Rails and Postgres: Getting the latest entry for each customer

I have a model (which has been simplified here) that looks like this:
class Customer < ActiveRecord::Base
attr_accessor :name
has_many :orders
end
class Order < ActiveRecord::Base
attr_accessor :description, :cost, :date
end
I'm using Postgres – what's the best way to run a query that will return me a single result that contains the latest order for each customer?
If I do something like this:
Order.order('date DESC').group('customer')
Then I get a Postgres error:
PGError: ERROR: column must appear in the GROUP BY clause or be used in an aggregate function
What's the cleanest way to do this? I'm a Rails newbie, so let me know if I've left out any vital information here.
as question says Getting the latest entry for each customer
Customer.all.map{|C| [c.name, c.orders.last]}
will return an array with customer name and latest order. In view it will look like this:
<% #customers.each do |c| %>
<%= c.name %> - <%= c.orders.last.cost %>
<% end %>
result:
John Travolta - $5454
Johnny Depp - $6849
Order.order('date DESC').group('customer_id')
I guess your Order model does not have customer field by convention it should be customer_id
For a more SQL oriented method, which ought to be better performing:
Order.where("id in (select max(id) from orders group by customer_id)")
It relies on the most recent order having the highest id.

How to group an object by its associated model when using a has_many :through relationship in rails?

I am working on an application that helps a local restaurant track the hours worked each day. The user enters data through a model called a "Checkout" at the end of each shift. They select an employee that is associated with a has_many :through => employment, where the employment model is a join table. Granted, Checkout is a confusing term, but that is the DSL that the restaurant wanted to use.
How do you group the hours worked by each employee on a given day? There might be 2 checkouts in the database for that employee on that day – one from lunch and one from dinner.
I want to create a list of hours that each employee worked on a given day when their hours from that day might be stored in separate checkouts in the database.
Today's Date
Joe Employee
Hours worked: 12
Jill Employee
Hours worked: 4
etc.
How do I group the checkouts by employee when they are not an attribute on the checkouts model, but rather are an association through my employment model? Essentially, I'm trying to do something like this in my reports helper:
def checkouts_today
current_user.checkouts.where( :date => Date.today )
end
def employee_hours_today
checkouts_today.where( :employment => Employee.find_by(id: params[:id]) ).sum(:hours)
end
But I can't get it to work with the has_many :through association. I can only figure out how to total all hours from that day, not the total hours per employee.
Here are the relevant files:
models/checkout.rb - https://gist.github.com/leemcalilly/a2a0578adaf8841d4d5e
models/employee.rb - https://gist.github.com/leemcalilly/2df5e7fb7db0ac0f602c
models/employment.rb - https://gist.github.com/leemcalilly/d3a028b6effe5f245b2a
helpers/reports_helper.rb - https://gist.github.com/leemcalilly/e2503188bb26df64ad20
view/payroll_processing.html.erb - https://gist.github.com/leemcalilly/868b1272128e75dc60d0
db/schema.rb - https://gist.github.com/leemcalilly/f9ce764d16161b7b3017
Update:
I can loop through each employee and display their checkouts with this:
<% #employees.each do |employee| %>
<% if employee.checkouts.present? %>
<p><%= employee.full_name %></p>
<% employee.checkouts.each do |checkout| %>
<%= checkout.date %>
<%= checkout.shift %>
<%= checkout.hours %>
<% end %>
<% end %>
<% end %>
But, I can't figure out how to sum their hours and display and return it in one block. It returns each checkout for each day under each employee's name. It also seems like this kind of logic should not be in the view.
1st method
in checkout.rb:
scope :checkouts_today, -> {where(date: Date.today)}
in your view or controller:
employee.checkouts.checkouts_today.sum(:hours)
2nd method
in employee.rb
has_many :today_checkouts, class_name: "Checkout", conditions: {date: Date.today}
in your view or controller:
employee.today_checkouts.sum(:hours)
Not sure the 2nd way would work as you have a join table to get the relation between Employee and Checkout. As we discussed in chat, you can drop the join table, in your case I don't see where you'll need it, give your app more space.

How to list top 10 school with active record - rails

I have two models: School and Review.
The School model looks like this: {ID, name, city, state}
The Review model looks like this: {ID, content, score, school_id}
How do I list the top ten schools based on the score from the review model?
I thought maybe a method in the school-model, with something like this:
class School < ActiveRecord::Base
def top_schools
#top_schools = School.limit(10)
...
end
end
And then loop them in a <li> list:
<div>
<ul>
<% #top_schools.each do |school| %>
<li>school.name</li>
<%end>
</ul>
</div>
But, I dont really know how to finish the top_schools method.
You should make an average of reviews of each school.
The SQL query if you are running with MySQL should be something like:
SELECT schools.* FROM schools
JOIN reviews ON reviews.school_id=schools.id
GROUP BY schools.id
ORDER BY AVG(reviews.score) DESC
LIMIT 10
Translated in Rails:
In your School model:
scope :by_score, :joins => :reviews, :group => "schools.id", :order => "AVG(reviews.score) DESC"
In your controller:
#top_schools = School.by_score.limit(10)
The choice not to include the limitation in scope, can be more flexible and allow the display of 5 or 15.
I have only tested MySQL request. I am not sure on my rails translation.
Assuming that each school only has one review ( has_one and belongs_to), you'd want to order the reviews first and then find the corresponding school:
Review.order('score DESC').first(10).each do |r|
School.find_by_id(r.school_id)
end
I would add a new field total_score to the schools table - set to 0 by default. And then add a callback in the Review model to calculate the total score for the school when a new review is added/updated to that school.
Then do this:
School.order("total_score DESC").limit(10)
Edit: I totally missed the reviews model in that answer.
School.all(:select => "schools.*, AVG(reviews.score) as avg_score",
:joins => :reviews,
:group => 'schools.id',
:order => 'avg_score desc',
:limit => 10)
But this will get slower as you add reviews. I like the total_score answer.

Rails ActiveRecord - Best way to perform an include?

I have three models:
class Book < ActiveRecord::Base
has_many :collections
has_many :users, :through => :collections
end
class User < ActiveRecord::Base
has_many :collections
has_many :books, :through => :collections
end
class Collection < ActiveRecord::Base
belongs_to :book
belongs_to :user
end
I'm trying to display a list of the books and have a link to either add or remove from the user's collection. I can't quite figure out the best syntax to do this.
For example, if I do the following:
Controller
class BooksController < ApplicationController
def index
#books = Book.all
end
end
View
...
<% if book.users.include?(current_user) %>
...
or obviously the inverse...
...
<% if current_user.books.include?(book) %>
...
Then queries are sent for each book to check on that include? which is wasteful. I was thinking of adding the users or collections to the :include on the Book.all, but I'm not sure this is the best way. Effectively all I need is the book object and just a boolean column of whether or not the current user has the book in their collection, but I'm not sure how to forumlate the query in order to do that.
Thanks in advance for your help.
-Damien
I have created a gem(select_extra_columns) for returning join/calculated/aggregate columns in a ActiveRecord finders. Using this gem, you will be able to get the book details and the flag indicating if the current user has the book in one query.
In your User model register the select_extra_columns feature.
class Book < ActiveRecord::Base
select_extra_columns
has_many :collections
has_many :users, :through => :collections
end
Now in your controller add this line:
#books = Book.all(
:select => "books.*, IF(collections.id, 1, 0) AS belongs_to_user",
:extra_columns => {:belongs_to_user => :boolean},
:joins => "LEFT OUTER JOIN collections
ON book.id = collections.book_id AND
collections.user_id = #{current_user.id}"
)
Now in your view you can do the following.
book.belongs_to_user?
You're going to to want 2 SQL queries, and O(1) based lookups (probably irrelevant, but it's the principle) to check if they have the book.
The initial calls.
#books = Book.all
#user = User.find(params[:id], :include => :collections)
Next, you're going to want to write the books the user has into a hash for constant time lookup (if people won't ever have many books, just doing an array.include? is fine).
#user_has_books = Hash.new
#user.collections.each{|c|#user_has_books[c.book_id] = true}
And on the display end:
#books.each do |book|
has_book = #user_has_books.has_key?(book.id)
end
I'd err away from caching the book_ids on the user object, simply because going this route can have some funny and unexpected consequences if you ever start serializing your user objects for whatever reason (i.e. memcached or a queue).
Edit: Loading intermediary collection instead of double loading books.
Essentially you need to make one call to get the book information and the Boolean flag indicating if the current user has the book. ActiveRecord finders doesn't allow you to return the join results from another table. We work around this problem by doing a trick.
In your Book model add this method.
def self.extended_book
self.columns # load the column definition
#extended_user ||= self.clone.tap do |klass|
klass.columns << (klass.columns_hash["belongs_to_user"] =
ActiveRecord::ConnectionAdapters::Column.new(
"belongs_to_user", false, "boolean"))
end # add a dummy column to the cloned class
end
In your controller use the following code:
#books = Book.extended_book.all(
:select => "books.*, IF(collections.id, 1, 0) AS belongs_to_user",
:joins => "LEFT OUTER JOIN collections
ON book.id = collections.book_id AND
collections.user_id = #{current_user.id}"
)
Now in your view you can do the following.
book.belongs_to_user?
Explanation:
In the extended_book method you are creating a copy of Book class and adding a dummy column belongs_to_user to the hash. During the query extra join column is not rejected as it exists in the columns_hash. You should use the extended_book only for querying.
If you use it for CRUD operations DB will throw error.
I would first create an instance method in the User model that 'caches' the all the Book ID's in his collection:
def book_ids
#book_ids ||= self.books.all(:select => "id").map(&:id)
end
This will only execute the SQL query once per controller request. Then create another instance method on the User model that takes a book_id as a parameter and checks to see if its included in his book collection.
def has_book?(book_id)
book_ids.include?(book_id)
end
Then while you iterate through the books:
<% if current_user.has_book?(book.id) %>
Only 2 SQL queries for that controller request :)
Use exists? on the association as it is direct SQL call. The association array is NOT loaded to perform these checks.
books.users.exists?(current_user)
This is the SQL executed by Rails.
SELECT `users`.id FROM `users`
INNER JOIN `collections` ON `users`.id = `collections`.user_id
WHERE (`users`.`id` = 2) AND ((`collections`.book_id = 1)) LIMIT 1
In the above SQL current_user id = 2 and book id is 1
current_user.books.exists?(book)
This is the SQL executed by Rails.
SELECT `books`.id FROM `books`
INNER JOIN `collections` ON `books`.id = `collections`.book_id
WHERE (`books`.`id` = 3) AND ((`collections`.user_id = 4)) LIMIT 1
In the above SQL current_user id = 4 and book id is 3
For more details, refer to the documentation of the exists? method in a :has_many association.
Edit: I have included additional information to validate my answer.

Resources