optimizing select query on has_many :through attributes association - ruby-on-rails

I want to find all posts that are tagged with tags that are passed in a params array.
post has many tags through association.
currently my code looks like this:
if params.has_key?(:tags)
params[:tags].each do |tag|
#tags = Array.new if #tags.nil?
#tag = Tag.find_by_content(tag)
#tags << #tag if #tag
end
#allposts = Post.followed_by(#user).select { |p| p.tags.size != 0 && (p.tags & #tags).size == p.tags.size }
else
#allposts = Post.followed_by(#user)
end
what i'm basically doing is finding the actual tag models according to the params array and putting them into an array, then I run a select query on all posts searching for those with the same tags array.
is there a better and cleaner way to do this ?

You can roll your Tag.find query into a single request to the DB, and add an appropriate where clause to limit the posts returned:
finder = Post.followed_by(#user)
if params.has_key?(:tags)
#tags = Tag.where(:content => params[:tags])
finder = finder.with_tags(#tags)
end
#allposts = finder.all
in app/models/post.rb
scope :with_tags, lambda { |tags| joins(:tags).group('posts.id').where(:tags => { :id => tags.map { |t| t.id } } ).having("COUNT(*) = ?", tags.length) }
UPDATE
Here's what the with_tags scope does:
joins(:tags) Firstly we join the tags table to the posts table. Rails will do with with an inner join when you use the symbol syntax
where(:tags => { :id => tags.map { |t| t.id } } ) We want to filter the tags to only find those tags provided. Since we are providing a list of tag objects we use map to generate an array of IDs. This array is then used in the where clause to create a WHERE field IN (list) query - the hash within a hash syntax is used to denote the table, then column within the table.
group('posts.id') So now that we have a list of posts with the requisite tags, however, if there are multiple tags we will have posts listed multiple times (once for each matched tag), so we group by the posts.id so that we only have 1 row returned for each post (it's also required to that we can do the count in step 4)
having("count(*) = ?", tags.length) This is the final piece of the puzzle. Now that we've grouped by the post, we can count the number of matched tags associated with this post. So long as duplicate tags are not allowed then if the number of matched tags (count(*)) is the same as the number of tags we were searching with (tags.length) Then we can be sure that the post has all the tags we were searching with.
You can find a lot more information about the different query methods available for models by reading the Active Record Query Interface Guide

Related

Filtering objects by checking if multiple entries in many-to-many table exist

I have three tables: listings, amenities, and listing_amenities. I have a filter where users can filter listings by amenities, and in my controller I take in their filter as an array of amenity descriptions. I am trying to filter for listings which have ALL of those amenities. Currently, I can filter, but it only succeeds to check if listings have at least ONE of the provided amenities.
Current query:
scope :filter_by_amenities, ->(amenities) { # amenities is array of descriptions
includes(:listing_amenities)
.where(listing_amenities: {
:amenity_id => (
Amenity.where(:description => amenities)
)
})
}
How can I modify the query to only return listings which have ALL of the amenities, rather than at least one?
This can be done using class method and querying on the resultant records iteratively.
def self.filter_by_amenities(amenities)
amenity_ids = Amenity.where(:description => amenities).pluck(:id)
records = self.all
amenity_ids.each do |amenity_id|
record_ids = records.includes(:listing_amenities).where(listing_amenities: {:amenity_id => amenity_id)}).pluck(:id)
records = records.where(id: record_ids)
end
records
end

How to create a Rails scope that matches multiple HABTM records

I have a Rails 5 app acting like a CMS, with Story and Tag models. A story has_and_belongs_to_many :tags. I want to create a scope in which I can pass multiple tags and get all stories that have ALL the tags I pass to it.
For example:
story_1.tags # => [tag_a, tag_c]
story_2.tags # => [tag_b, tag_c]
story_3.tags # => [tag_a, tag_b, tag_c]
# Desired behavior
Story.with_tags([tag_a, tag_c]) # => [story_1, story_3]
Story.with_tags([tag_b, tag_c]) # => [story_2, story_3]
Story.with_tags([tag_a, tag_b]) # => [story_3]
I've tried making a single with_tag scope and chaining multiple together, but it seems to make a query that attempts to find a single join record where the tag ID is 1 AND 3, which returns nothing.
def self.with_tag(tag)
joins(:tags).where(tags: { id: tag })
end
Story.with_tag(tag_a).with_tag(tag_c) # => []
I've also tried passing all the tag IDs into a single where clause on the join table, but then I get all stories that have any of the tags (more of an OR query, I'm looking for an AND)
def self.with_tags(tags)
joins(:stories_tags).where(stories_tags: { tag_id: tags }).distinct
end
Story.with_tags([tag_a, tag_c]) # => [story_1, story_2, story_3]
You have to use a SQL HAVING clause:
ids = [1,2,3]
Story.joins(:tags)
.where(:tags => { id: ids })
.group('stories.id')
.having('count(tags.id) >= ?', ids.size)
# ^^ if you want to get stories having exactly the tags
# provided, use equal instead
Similar question: Rails filtering records in many to many relationship

How to order records by result of grouped hash in 'belongs_to' relationship

I would like to map records and order them by the results of a hash that counts the number of records in another model that has a 'belongs_to' association with the first.
tag_follow.rb
belongs_to :tag
belongs_to :user
I have a model tag.rb with the following methods
def self.follow_counts(user)
counts = TagFollow.group(:tag_id).count
Tag.order(:tag).map{|t|
t.followed_count = counts[t.id].to_i
t
}
end
def followed_count
#followed_count ||= TagFollow.where(:tag_id => self.id).count
end
Instead of ordering the tag array by the column :tag as it currently is, I would like it to be ordered by the count that is the value in the hash of the returned counts variable, matching the key with :tag_id.
What is the easiest way to do this?
You can set up a named scope in your Tag model like this:
scope :popular, -> {
joins(:tag_follows)
.group("tags.id")
.order("COUNT(*) DESC")
}
and then use it like this:
Tag.popular.all
to get all of the Tags (that have at least 1 associated tag_follows record) in the order of how many tag_follows records they are referenced in.
If you want your collection to include Tags that have 0 associated tag_follows records, so that every Tag will be in the collection, you can use a LEFT JOIN like this:
scope :popular, -> {
joins("LEFT JOIN tag_follows ON tag_follows.tag_id = tags.id")
.group("tags.id")
.order("COUNT(tag_follows.tag_id) DESC")
}
Notice that I changed the order parameter so that Tags with 0 tag_follows records will be ranked after Tags with 1 tag_follows record.

Selecting posts with multiple tags

I'm implementing a tagging system on a blog app. This app has posts, posts have many tags through taggings. More or less like RailCasts #382 http://railscasts.com/episodes/382-tagging
I will use checkboxes to select posts with multiple tags like this:
Post.joins(:tags).where(:tags => { :id => [tag_ids] } )
But what if I want to join posts that have all the required tags instead of posts that meet only one requirements?
For exapmle:
Post1 has tags "foo, bar, baz"
Post2 has tags "bar, baz"
Post3 has tags "bar"
If I search for ["bar", "baz"] my method returns posts 1, 2 and 3. What If I want to return only post 1 and 2?
In SQL, you would do something like
GROUP BY posts.id
HAVING count(tags.name) = 2
In rails it translates to
Post.joins(:tags).select('posts.id').where(:tags => { :id => [tag_ids] }
).having("count(tags.name) = ?", tag_ids.count).group('posts.id')
The above code assumes that tag_ids is an array of ids. Also, it only loads the ids for the returned set of ActiveRecord Post objects.
If you want to load more fields, add them in the select call, or otherwise remove the select call to load all Post fields. Just remember that for every column/field from Post that is retrieved in the resulting SQL query, you will need to add that field to the group call.

rails/ruby: filtering

query = Micropost.order("created_at desc")
unless params[:tag_id].blank? or params[:tag_id] == "Select a tag"
tags = Tag.all
params[:tag_id].each do |index|
query = tags[Integer(index) - 1].microposts.order("created_at desc") & query
end
end
This is the code I have. Basically tags have microposts and when I specify an array of tags from params[:tag_id] (I use a multiple select_tag), I want the intersection of all those microposts specified by the tags.
This code works when the array has only one tag but doesnt seem to work with more than 1. Wheres the bug?
I'm not sure if I fully understand what you are trying to do. But perhaps something along these lines is what you are after (goes inside the unless)?
For posts that have any tag:
tags = Tags.where(:id => params[:tag_id]).all
posts_with_tags = tags.map(&:microposts).flatten.uniq
For posts that have all tags:
tags = Tags.where(:id => params[:tag_id]).all
posts_with_tags = tags.map(&:microposts).inject { |memo, elem| memo & elem }
if you want the intersection of all Micropost with the collection of microposts associated with the tags selected you have only to query for all the micropost associated with the selected Tag and collect the microposts.
with rails3
unless params[:tag_id].blank? or params[:tag_id] == "Select a tag"
query= Tag.where(["id in (?)",params[:tag_id]]).collect(&:microposts).uniq
end
maybe i have misunderstood your question , sorry .

Resources