Pluck associated model's attribute in Rails query - ruby-on-rails

In my rails app, collections have many projects, and projects have many steps.
I'd like to grab all the ids of steps in a collection's projects, and I'm wondering if I can do it all in one query.
For example, I know I can do the following
step_ids = []
#collection.projects.each do |project|
project.steps.each do |step|
step_ids << step.id
end
end
But is it possible to do something like the following:
#collection.projects.include(:steps).pluck("step.id") // syntax here is not correct

Try this:
Step.joins(:project).where(projects: { collection_id: #collection.id }).pluck(:'steps.id')
Note the use of project for the joins, and then projects for the where clause. The first corresponds to the belongs_to relationship, and the latter is the name of the db table.
edit: in the case of a many-to-many relationship between projects and collections, and assuming a project belongs_to a project_collection (and then has_many :collections, through :project_collection)
Step.joins(:project => :project_collection)
.where(project_collections: { collection_id: #collection.id })
.pluck(:'steps.id')

Unfortunately, I don't think that we could do it through AR in a single query. You could do a nested query below to retrieve it in two queries to the database:
Step.includes(:projects)
.where(projects: { id: Projects.includes(:collections)
.where(collections: { id: #collections.id }).pluck(:id) } )
.pluck(:id)

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)

Find records with at least one association but exclude records where any associations match condition

In the following setup a customer has many tags through taggings.
class Customer
has_many :taggings
has_many :tags, through: :taggings
end
class Tagging
belongs_to :tag
belongs_to :customer
end
The query I'm trying to perform in Rails with postgres is to Find all customers that have at least one tag but don't have either of the tags A or B.
Performance would need to be taken into consideration as there are tens of thousands of customers.
Please try the following query.
Customer.distinct.joins(:taggings).where.not(id: Customer.joins(:taggings).where(taggings: {tag_id: [tag_id_a,tag_id_b]}).distinct )
Explanation.
Joins will fire inner join query and will make sure you get only those customers which have at least one tag associated with them.
where.not will take care of your additional condition.
Hope this helps.
Let tag_ids is array of A and B ids:
tag_ids = [a.id, b.id]
Then you need to find the Customers, which have either A or B tag:
except_relation = Customer.
joins(:tags).
where(tags: { id: tag_ids }).
distinct
And exclude them from the ones, which have at least one tag:
Customer.
joins(:tags).
where.not(id: except_relation).
distinct
INNER JOIN, produced by .joins, removes Customer without Tag and is a source of dups, so distinct is needed.
UPD: When you need performance, you probably have to change your DB schema to avoid extra joins and indexes.
You can search examples of jsonb tags implementation.
Get ids of tag A and B
ids_of_tag_a_and_b = [Tag.find_by_title('A').id, Tag.find_by_title('B').id]
Find all customers that have at least one tag but don't have either of the tags A or B.
#Customer.joins(:tags).where.not("tags.id in (?)", ids_of_tag_a_and_b)
Customer.joins(:tags).where.not("tags.id = ? OR tags.id = ?", tag_id_1, tag_id_2)

includes with multiple levels of foreign tables

Vacancies have matchings, matchings have rooms, rooms have messages
I need to get the vacancies that follow the specific criteria of a matching attribute and then filter them again based on wether they have messages from an employee.
Vacancy.created_this_week
.includes(:matchings, :rooms, :messages)
.where(matchings: {state: ["applied", "accepted", "denied"]})
.where(messages: {from_employee: false}.count
Although I get following:
Can't join 'Vacancy' to association named 'rooms'; perhaps you misspelled it?
I understand the association is based on a matching but how else would I get this to fit in one query since I need to filter out an amount of vacancies too?
EDIT
Based on an answer below I tried
.includes(matchings: { rooms: :messages })
Which gives me
Can't join 'Matching' to association named 'room'; perhaps you misspelled it?
Sanity check:
>> Matching.first.room.messages
=> #<ActiveRecord::Associations::CollectionProxy []>
Try nesting the includes in Hash format:
.includes(matchings: { rooms: :messages })

Query records through its belongs_to relation in Rails

I have an Activities model, and they belong_to a Location
How do i select all the activities whose location.country = Australia? (for example)
Can I do this within a scope?
With the latest rails versions you can do:
Activity.joins(:location).where(locations: { country: "Australia" })
Beware:
it is location (singular) in joins(:location) because it references the belongs_to relationship name
it is locations (plural) in where(…) because it references the table name
The latter means that if you had the following:
belongs_to :location, class_name: "PublicLocation"
the query would be:
Activity.joins(:location).where(public_locations: { country: "Australia" })
The kind of query you're talking about is a join. You can try queries like this in the console like:
Activity.joins(:locations).where('locations.country = "Australia"')
This means that SQL is going to take all the activities and locations associated with then, find the locations where country=Australia, and then return you the activities that are associated with those locations.
To make this into a more reusable scope, define it on your model with a variable for country:
scope :in_country, lambda {|country| joins(:locations).where('locations.country = ?',country)}
You can learn more about this in the API docs.
Yes, a scope can be used. Something like this ought to work on the Activities model:
scope :down_under,
joins(:locations).
where("locations.country = 'Australia')

Resources