Rails, Has and belongs to many, match all conditions - ruby-on-rails

I have two models Article and Category
class Article < ApplicationRecord
has_and_belongs_to_many :categories
end
I want to get Articles that have category 1 AND category 2 associated.
Article.joins(:categories).where(categories: {id: [1,2]} )
The code above won't do it because if an Article with category 1 OR category 2 is associated then it will be returned and thats not the goal. Both must match.

You can query only those articles of the first category, which are also the articles of the second category.
It's going to be something like this:
Article.joins(:categories)
.where(categories: { id: 1 })
.where(id: Article.joins(:categories).where(categories: { id: 2 }))
Note, that it can be:
Category.find(1).articles.where(id: Category.find(2).articles)
but it makes additional requests and requires additional attention to the cases when category can't be found.

The way to do it is to join the same table multiple times. Here is an untested class method on Article:
def self.must_have_categories(category_ids)
scope = self
category_ids.each do |category_id|
scope = scope.joins("INNER JOIN articles_categories as ac#{category_id} ON articles.id = ac#{category_id}.article_id").
where("ac#{category_id}.category_id = ?", category_id)
end
scope
end

Related

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.

Get records order by count of related Many to Many association

Scnerio:
https://www.funtraker.com is listing movies, tv shows and games. On show page of each resource(Movie, Tv Show etc) we want to list down the related resources.
Schema:
class Movie < AR::Base
has_many :resource_genres, as: :resource
has_many :genres, through: :resource_genres
end
class ResourceGenre
belongs_to :resource, polymorphic: true
end
Now I want to get a list of related movies based on matched genre( two movies are related if both has 'comedy` genre). And these related movies need to order by max number of matched genres.
Well here is sample movies and the expected output.
#Input
Movie Genres
Movie 1: horror, comedy, action, war
Movie 2: action, thriller, crime, animation
Movie 3: comedy, war, action, thriller
Movie 4: crime, animation, action, war
#Expected output
movie1.related_movies => [ movie3, movie2 ]
movie4.related_movies => [ movie2, remaining-three-movies-in-any-order ]
movie3.related_movies => [ movie1, movie2, movie4]
Hopefully question make sense.
UPDATE: Looking for SQL only solution. I don't need to cache the results in any another table.
You need to order by the group count of the movies ids after joined with
resource_genres, take a look at the following pure SQL methods:
Method #1 (Single Query)
Double joining the resource_genres table on itself to maintain self genres ids:
def related_movies
Movie.select("movies.*, COUNT(*) AS group_count").
joins(:resource_genres).
joins("JOIN resource_genres rg ON rg.genre_id = resource_genres.genre_id").
where("rg.resource_type = 'Movie'
AND rg.resource_id = ?
AND movies.id != ?", self.id, self.id).
group('movies.id').
order('group_count DESC')
end
Method #2 (2 Queries)
Plucking the genre_ids from self resource_genres on a separate query.
def related_movies
Movie.select("movies.*, COUNT(*) AS group_count").joins(:resource_genres).
where("resource_genres.genre_id IN (?)
AND movies.id != ?", self.resource_genres.pluck(:genre_id), self.id).
group('movies.id').
order('group_count DESC')
end
If you are finding a solution in rails code, then it might solve your problem.
def related_movies
scores_hash = {}
Movie.joins(:resource_genres).where('resource_genres.genre_id' => resource_genres.pluck(&:genre_id)).where.not(id: self.id).distinct.find_each do |movie|
scores_hash[movie] = (movie.resource_genres.pluck(:genre_id) & self.resource_genres.pluck(:genre_id)).count
end
Hash[scores_hash.sort_by { |movie, score| -score }].keys
end

Ruby on Rails: Activerecord collection associated models

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)

Resources