Ruby on Rails: Activerecord collection associated models - ruby-on-rails

I have two models as follows:
class Bookshelf < ActiveRecord::Base
has_many :books
scope :in_stock, -> { where(in_stock: true) }
end
class Book < ActiveRecord::Base
belongs_to :bookshelf
end
I would like to find all the books in a collection of bookshelves based on a column in the bookshelf table efficiently.
At the moment I have to loop through each member as follows:
available_bookshelves = Bookshelf.in_stock
This returns an activerecord relation
To retrieve all the books in the relation, i am looping through the relation as follows:
available_bookshelves.each do |this_bookshelf|
this_bookshelf.books.each do |this_book|
process_isbn this_book
end
end
I would like all the books from the query so that I don't have to loop through each "bookshelf" from the collection returned individually. This works but feels verbose. I have other parts of the app where similar queries-loops are being performed.
EDIT:
Some clarification: Is there a way to get all books in all bookshelves that fit a certain criteria?
For example, if there are 5 brown bookshelves, can we retrieve all the books in those bookshelves?
something like (this is not valid code)
brown_books = books where bookshelf is brown

You can use the following query to get the books in the in stock book shelves
available_books = Book.where(bookshelf_id: Bookshelf.in_stock.select(:id))
That will run a single query which will look like:
SELECT books.*
FROM books
WHERE books.bookshelf_id IN (SELECT id FROM bookshelves WHERE in_stock = true)

Related

Finding all records that has at least one association from associated models (tables)

So let's say I have this:
class Tree < ActiveRecord::Base
has_many :fruits
has_many :flowers
end
class Fruit < ActiveRecord::Base
belongs_to :tree
end
class Flower < ActiveRecord::Base
belongs_to :tree
end
How can I make an efficient query that would get all the Tree instances that would have at least one Flower or Fruit instance, or both? The idea is not getting the Tree that don't have any Flower and Fruit at all.
I'd use such query:
Tree.left_joins(:fruits, :flowers).where('fruits.id IS NOT NULL OR flowers.id IS NOT NULL').distinct
it will produce this SQL:
SELECT DISTINCT "trees".* FROM "trees" LEFT OUTER JOIN "fruits" ON "fruits"."tree_id" = "trees"."id" LEFT OUTER JOIN "flowers" ON "flowers"."tree_id" = "trees"."id" WHERE (fruits.id IS NOT NULL OR flowers.id IS NOT NULL)
A very basic way to get all Trees that have at least one Flower or Fruit is like this
flowers = Flower.pluck(:tree_id)
fruits = Tree.pluck(:tree_id)
and then the Trees you want
Tree.where(id: [flowers, fruits].flatten)
This results in 3 queries to your db, which might or might not be a problem depending on your use case.
A more advanced method would be to do this, which results in only one query. Note the use of select instead of pluck which makes it possible for Rails to issue just one query in the end.
flowers = Flower.select(:tree_id)
fruits = Fruit.select(:tree_id)
Tree.where(id: flowers).or(Tree.where(id: fruits))

How to get all records that also "own" specific records performantly

I have the following two models in Rails application
class Students < ApplicationRecord
has_many :courses
attr_accessor :name
end
class Courses < ApplicationRecord
has_many :students
attr_accessor :name, :course_id
end
I would like to get a a list of all courses shared for each student who has been in the same class as a selected student in an efficient manner.
Given the following students:
Jerry's courses ["math", "english", "spanish"],
Bob's courses ["math", "english", "french"],
Frank's courses ["math", "basketweaving"]
Harry's courses ["basketweaving"]
If the selected student was Jerry, I would like the following object returned
{ Bob: ["math", "english"], Frank: ["math"] }
I know this will be an expensive operation, but my gut tells me there's a better way than what I'm doing. Here's what I've tried:
# student with id:1 is selected student
courses = Student.find(1).courses
students_with_shared_classes = {}
courses.each do |course|
students_in_course = Course.find(course.id).students
students_in_course.each do |s|
if students_with_shared_classes.key?(s.name)
students_with_shared_classes[s.name].append(course.name)
else
students_with_shared_classes[s.name] = [course.name]
end
end
end
Are there any ActiveRecord or SQL tricks for a situation like this?
I think you're looking to do something like this:
student_id = 1
courses = Student.find(student_id).courses
other_students = Student
.join(:courses)
.eager_load(:courses)
.where(courses: courses)
.not.where(id: student_id)
This would give a collection of other students that took courses with only two db queries, and then you'd need to narrow down to the collection you're trying to create:
course_names = courses.map(&:name)
other_students.each_with_object({}) do |other_student, collection|
course_names = other_student.courses.map(&:name)
collection[other_student.name] = course_names.select { |course_name| course_names.include?(course_name) }
end
The above would build out collection where the key is the student names, and the values are the array of courses that match what student_id took.
Provided you setup a join model (as required unless you use has_and_belongs_to_many) you could query it directly and use an array aggregate:
# given the student 'jerry'
enrollments = Enrollment.joins(:course)
.joins(:student)
.select(
'students.name AS student_name',
'array_agg(courses.name) AS course_names'
)
.where(course_id: jerry.courses)
.where.not(student_id: jerry.id)
.group(:student_id)
array_agg is a Postgres specific function. On MySQL and I belive Oracle you can use JSON_ARRAYAGG to the same end. SQLite only has group_concat which returns a comma separated string.
If you want to get a hash from there you can do:
enrollments.each_with_object({}) do |e, hash|
hash[e.student_name] = e.course_names
end
This option is not as database independent as Gavin Millers excellent answer but does all the work on the database side so that you don't have to iterate through the records in ruby and sort out the courses that they don't have in common.

Only return associative records that include all IDs in array

Say I have the following tables and relationships:
class Book
has_many :book_genres
end
class Genre
has_many :book_genres
end
class BookGenre
belongs_to :book
belongs_to :genre
end
If I wanted to only find the books that have two specific genre IDs (13005 and 9190),
I was thinking I could do something like:
Book.where(book_genres: {genre_id: [13005, 9190]})
But it's returning books that have either Genres of ID 13005 OR 9190.
I also thought maybe I could filter it down by doing something like:
Book.where(book_genres: {genre_id: [13005]}).where(book_genres: {genre_id: [9190]})
But that's not working either.
How would I only find the Books that contain Genres with both IDs?
Notes:
From this post (https://stackoverflow.com/a/3416838/1778314), I tried doing this:
Book.joins(:book_genres).where(book_genres: { genre_id: [13005,9190]}).group("books.id").having("count(book_genres.id) = #{[13005, 9190].count}")
But this doesn't work either (returns both sets).
Additionally, there's a similar post using Recipes and Ingredients with no working solution: Ruby On Rails 5, activerecord query where model association ids includes ALL ids in array
Books that have two specific genre IDs (13005 and 9190),
book_ids_either_have_two_ids = Book. includes(:book_genres).where("book_genres.id IN ?= [3005 9190]")distinct.pluck(:id)
Book.where.not("id IN ?", book_ids_either_have_two_ids)
I know following is not proficient way to do but can solve your problem,
Book.includes(:book_genres).select { |book| book.book_genres.map(&:genre_id).uniq.sort == [9190, 13005] }
Above will return ouput in Array type instead of ActiveRecord::Relation but it will work as per your requirement.
This worked for me:
Book.joins(:book_genres).where(book_genres: { genre_id: [13005, 205580]}).group("books.id").having('count(books.id) >= ?', [13005, 205580].size)

In a Rails 3 many to many association, what is the most efficient way to query objects based on conditions on their associations?

I have a many-to-many model relation:
class Movie
has_many :movie_genres
has_many :genres, :through => :movie_genres
class Genre
has_many :movie_genres
has_many :movies, :through => :movie_genres
class MovieGenre
belongs_to :movie
belongs_to :genre
I want to query all movies with a certain genre but not associated with another genre. Example: All movies that are Action but not Drama.
What I have done is this:
action_movies = Genre.find_by_name('action').movies
drama_movies = Genre.find_by_name('drama').movies
action_not_drama_movies = action_movies - drama_movies
Is there a more efficient way of doing this? It should be noted that the query can become more complex like: All movies that are Action but not Drama or All movies that are Romance and Comedy
You can indeed improve efficiency by avoid having to instantiate the Movie instances for all action and drama movies by removing the drama movies from the set of action movies in via the sql statement.
The basic building block is a dynamic scope similar to what widjajayd proposed
class Movie
...
# allows to be called with a string for single genres or an array for multiple genres
# e.g. "action" will result in SQL like `WHERE genres.name = 'action'`
# ["romance", "comedy"] will result in SQL like `WHERE genres.name IN ('romance', 'comedy')`
def self.of_genre(genres_names)
joins(:genres).where(genres: { name: genres_names })
end
...
end
You can use that scope as a building block to get the movies you want
All movies that are action but not drama:
Movie
.of_genre('action')
.where("movies.id NOT IN (#{Movie.of_genre('drama').to_sql}")
This will result in an sql subquery. Using a join would be nicer but it should be good enough for most cases and is a better read that the join alternative.
If your app where a rails 5 application you could even type
Movie
.of_genre('action')
.where.not(id: Movie.of_genre('drama'))
All movies that are Action but not Drama or All movies that are Romance and Comedy
Because it is a rails 3 app you will have to type move most of the sql by hand and can not make a lot of use of the scope. The or method is only introduced in rails 5. So this will mean having to type:
Movie
.joins(:genres)
.where("(genres.name = 'action' AND movies.id NOT IN (#{Movie.of_genre('drama').to_sql}) OR genres.name IN ('romance', 'comedy')" )
Again, if it where a rails 5 application this would be much simpler
Movie
.of_genre('action')
.where.not(id: Movie.of_genre('drama'))
.or(Movie.of_genre(['romance', 'comedy']))
probably using scope is better, here is sample and explanation (but not tested), create scope in movie model as follow
Movie.rb
scope :action_movies, joins(movie_genre: :genre).select('movies.*','genres.*').where('genres.name = ?', 'action')
scope :drama_movies, joins(movie_genre: :genre).select('movies.*','genres.*').where('genres.name = ?', 'drama')
in your controller, you can call as follow
#action_movies = Movie.action_movies
#drama_movies = Movie.drama_movies
#action_not_drama_movies = #action_movies - #drama_movies
edit for dynamic scope
if you want dynamic then you can send parameter to scope below is scope using block.
scope :get_movies, lambda { |genre_request|
joins(movie_genre: :genre).select('movies.*','genres.*').where('genres.name = ?', genre_request)
}
genre_request = parameter variable for scope
in your controller
#action_movies = Movie.get_movies('action')
#drama_movies = Movie.get_movies('drama')
Don't see a way to do it with one query (not without using subqueries anyway). But here is one that I think makes it a little better:
scope :by_genres, lambda { |genres|
genres = [genres] unless genres.is_a? Array
joins(:genres).where(genres: { name: genre }).uniq
}
scope :except_ids, lambda { |ids|
where("movies.id NOT IN (?)", ids)
}
scope :intersect_ids, lambda { |ids|
where("movies.id IN (?)", ids)
}
## all movies that are action but not drama
action_ids = Movie.by_genres("action").ids
drama_movies = Movie.by_genres("drama").except_ids(action_ids)
## all movies that are both action and dramas
action_ids = Movie.by_genres("action").ids
drama_movies = Movie.by_genres("drama").intersect_ids(action_ids)
## all movies that are either action or drama
action_or_drama_movies = Movie.by_genres(["action", "drama"])
It's possible to do except and intersect with raw SQL in Rails. But I think that's in general not a good idea as it still requires more than one query and also might make the code dependent on the DB used.
My original answer is rather naive. I'll leave it here so others won't make the same mistake:
Use joins and you can get it with one query:
Movie.joins(:genres).where("genres.name = ?", "action").where("genres.name != ?", "drama")
As noted in the comments, this will get all the movies that are both action and drama too.

How to query an ActiveRecord table using conditions based on another table?

I have two models - Blog and BlogTag. Blog has_many :blog_tags and BlogTag belongs_to :blog:
blog.rb
has_many :blog_tags
blog_Tag.rb
belongs_to :blog
I want to query the database to select all blogs that have tags matching in the blog_tags table based on what a user enters in a form field. Something like this:
Blog.where(blog_tags contain [array of tags])
Is there a way to do this with ActiveRecord?
Assuming BlogTag has a column name.
Blog.joins(:blog_tags).where(blog_tags: { name: [array of tags] }).uniq
This would still return Blogs, but only blogs with blog tags whose name is in the array.
Another approach to this is by passing a BlogTag relation into a Blog scope.
Blog.where(id: BlogTag.where(name: tags).select(:blog_id))
So instead of a JOIN, ActiveRecord will construct a subquery
SELECT * FROM blogs WHERE id IN (
SELECT blog_id FROM blog_tags WHERE name IN ('tag1', 'tag2', 'tag3')
)

Resources