Globally Reuse ActiveRecord Queries across Models? - ruby-on-rails

Not sure how to word the question concisely :).
I have say 20 Posts per page, and each Post has 3-5 tags. If I show all the tags in the sidebar (Tag.all.each...), then is there any way to have a call to post.tags not query the database and just use the tags found from Tag.all?
class Post < ActiveRecord::Base
end
class Tag < ActiveRecord::Base
end
This is how you might use it:
# posts_controller.rb
posts = Post.all
# posts/index.html.haml
- posts.each do |post|
render :partial => "posts/item", :locals => {:post => post}
# posts/_item.html.haml
- post.tags.each do |tag|
%li
%a{:href => "/tags/#{tag.name}"}= tag.name.titleize
I know about the following optimizations already:
Rendering partials using :collection
Eager loading with Post.first(:include => [:tags])
I'm wondering though, if I use Tag.all in my shared/_sidebar.haml template, is there anyway to reuse the result from that query in the post.tags calls?

You can use the tag_ids method on a Post instance.
In your controller create the tag hash. Better still cache the tag hash.
Add this to your application_controller.rb.
def all_tags
#all_tags ||=Rails.cache.fetch('Tag.all', :expire_in => 15.minutes)){ Tag.all }
# without caching
##all_tags ||= Tag.all
end
def all_tags_hash
#all_tags_hash ||= all_tags.inject({}){|hash, tag| hash[tag.id]=tag;hash}
end
def all_tags_by_ids ids
ids ||= []
ids = ids.split(",").map{|str| str.to_i} if ids.is_a?(string)
all_tags_hash.values_at(*ids)
end
helper_method :all_tags, :all_tags_hash, :all_tags_by_id
Now your partial can be rewritten as
# posts/_item.html.haml
- all_tags_by_ids(post.tag_ids).each do |tag|
%li
%a{:href => "/tags/#{tag.name}"}= tag.name.titleize
My solution caches the Tag models for 15 minutes. Make sure you add an observer/filter on Tag model to invalidate/update the cache during create/update/delete.
In your config\environment.rb
config.active_record.observers = :tag_observer
Add a tag_observer.rb file to your app\models directory.
class TagObserver < ActiveRecord::Observer
def after_save(tag)
update_tags_cache(tag)
end
def after_destroy(tag)
update_tags_cache(tag, false)
end
def update_tags_cache(tag, update=true)
tags = Rails.cache.fetch('Tag.all') || []
tags.delete_if{|t| t.id == tag.id}
tags << tag if update
Rails.cache.write('Tag.all', tags, :expire_in => 15.minutes)
end
end
Note: Same solution will work with out the cache also.
This solution still requires you to query the tags table for Tag ids. You can further optimize by storing tag ids as a comma separated string in the Post model(apart from storing it in post_tags table).
class Post < ActiveRecord::Base
has_many :post_tags
has_many :tags, :through => :post_tags
# add a new string column called tag_ids_str to the `posts` table.
end
class PostTag < ActiveRecord::Base
belongs_to :post
belongs_to :tag
after_save :update_tag_ids_str
after_destroy :update_tag_ids_str
def update_tag_ids_str
post.tag_ids_str = post.tag_ids.join(",")
post.save
end
end
class Tag < ActiveRecord::Base
has_many :post_tags
has_many :posts, :through => :post_tags
end
Now your partial can be rewritten as
# posts/_item.html.haml
- all_tags_by_ids(post.tag_ids_str).each do |tag|
%li
%a{:href => "/tags/#{tag.name}"}= tag.name.titleize

Related

Create new record with tags (has_many_through) when record isn’t persisted yet

I would like to implement custom tagging feature. So far I have Item and Tag models but I struggle with submission of tags – Rails keep telling me that Tag assignment is invalid whenever I try to create new Item.
(Creation of new item represents text area for item description and bunch of checkboxes with all existing tags from db.)
Curiously updating of tags of existing items works fine. This observation led me to discovery that culprit lies in fact that during build the item object is not persisted so it does not have an id yet, hence the relationship between item and tags cannot be established at the moment.
Based on this I have augmented create action in ItemsController with following contraption.
While this works it seems like ugly hack to me, so I would like to know what is the proper way™ to handle this situation.
class ItemsController < ApplicationController
⋮
def create
tags = {}
tags[:tag_ids] = item_params.delete('tag_ids')
reduced_params = item_params.reject{|k,v| k == 'tag_ids'}
#item = current_user.items.build(reduced_params)
#item.save
#item.update_attributes(tags)
Relevant code
class Item < ApplicationRecord
has_many :tag_assignments, foreign_key: 'tagged_item_id'
has_many :tags, through: :tag_assignments
⋮
class TagAssignment < ApplicationRecord
belongs_to :tagged_item, class_name: 'Item'
belongs_to :tag
⋮
class Tag < ApplicationRecord
has_many :tag_assignments
has_many :items, through: :tag_assignments
⋮
items_controller.rb
def create
#item = current_user.items.build(item_params)
⋮
private
def item_params
params.require(:item).permit(:description, :tag_ids => [])
end
_item_form.html.erb
⋮
<section class="tag_list">
<%= f.collection_check_boxes :tag_ids, Tag.all, :id, :name do |cb| %>
<% cb.label {cb.check_box + cb.text} %>
<% end %>
</section>
You might want to use virtual attributes, in this case tag_list , which will use either new or already created and persisted tags present in the database.
class Item < ApplicationRecord
def tag_list
self.tags.collect do |tag|
tag.name
end.join(", ")
end
def tag_list=(tags_string)
tag_names = tags_string.split(",").collect{|s| s.strip.downcase}.uniq
new_or_found_tags = tag_names.collect { |name| Tag.find_or_create_by(name: name) }
self.tags = new_or_found_tags
end
Now in your items form all you need from tags is to just refer the virtual attribute instead of
<%= f.label :tag_list %><br />
<%= f.text_field :tag_list %>
This should be simple and should result in less complicated code overall. Also do you not forget to change your current code accordingly. For example, in your items controller
private
def item_params
params.require(:item).permit(:description, :tag_list)
end
Now your item new and create actions can be as simple as
def new
#item = Item.new
end
def create
#item = Item.new(item_params)
#item.save
redirect_to ......
end
I would recommend using the acts_as_taggable_on gem. It solves almost all situations regarding the use of tags.
class User < ActiveRecord::Base
acts_as_taggable # Alias for acts_as_taggable_on :tags
acts_as_taggable_on :skills, :interests
end
class UsersController < ApplicationController
def user_params
params.require(:user).permit(:name, :tag_list) ## Rails 4 strong params usage
end
end
#user = User.new(:name => "Bobby")
#user.tag_list.add("awesome") # add a single tag. alias for <<
#user.tag_list.remove("awesome") # remove a single tag

Efficient counting of an association’s association

In my app, when a User makes a Comment in a Post, Notifications are generated that marks that comment as unread.
class Notification < ActiveRecord::Base
belongs_to :user
belongs_to :post
belongs_to :comment
class User < ActiveRecord::Base
has_many :notifications
class Post < ActiveRecord::Base
has_many :notifications
I’m making an index page that lists all the posts for a user and the notification count for each post for just that user.
# posts controller
#posts = Post.where(
:user_id => current_user.id
)
.includes(:notifications)
# posts view
#posts.each do |post|
<%= post.notifications.count %>
This doesn’t work because it counts notifications for all users. What’s an efficient way to do count notifications for a single user without running a separate query in each post?
Found a solution!
# posts controller
#posts = Post.where(…
#notifications = Notification.where(
:user_id => current_user.id,
:post_id => #posts.map(&:id),
:seen => false
).select(:post_id).count(group: :post_id)
# posts view
#posts.each do |post|
<%= #notifications[post.id] %>
Seems efficient enough.
You could do something like this:
#posts=Post.joins(:notifications).where('notification.user_id' => current_user.id)
Where notification.user_id is the id of the notification of current_user
I suggest creating a small class to encapsulate the logic for a collection of notifications:
class NotificationCollection
def self.for_user(user)
new(Notification.where(user_id: user.id))
end
def initialize(notifications)
#notifications = notifications
end
include Enumerable
def each(&block)
#notifications.each(&block)
end
def on_post(post)
select do |notification|
notification.post_id == post.id
end
end
end
Then, on your controller:
#user_notifications = NotificationCollection.for_user(current_user)
#posts = Post.where(user_id: current_user.id)
Finally, on your view:
#posts.each do |post|
<%= #user_notifications.on_post(post).count %>
end
That way, you only need to do a single notification query per user - not as performant as doing a COUNT() on the database, but should be enough if the notifications of a single user stay below the hundreds.

rails adding tags to controller

I am implementing a basic tagging feature to my app. New to rails.
I have a listings model and I am working in the listings_controller # index. When the user goes to the listing page and sets the :tag param I want #users only to hold the users which match the tag.
So if they goto www.example.com/listings?tag=foo only pages which have been tagged with foo are loaded. This is what I have come up with so far but there is several problems with it.
def index
if params[:tag]
#id = Tag.where(:name => params[:tag]).first.id
#listingid = Tagging.where(:tag_id => #id)
#listingid.each do |l|
#users = User.find(l.listing_id)
end
else
#users = User.all
end
end
I am not sure how to loop and add each found user to #users. I think I may be going about this whole thing the wrong way.. My tag/tagging models look as follows:
tag.rb
class Tag < ActiveRecord::Base
has_many :taggings
has_many :listings, through: :taggings
end
tagging.rb
class Tagging < ActiveRecord::Base
belongs_to :tag
belongs_to :listing
end
Taggings has the following colums:
id, tag_id, listing_id
Tags has the following columns:
id, name
Any guidance would be appreciated, been trying to fix this for a while with no luck.
Trying with
def index
#tag = Tag.where(:name => params[:tag]).first
if #tag
#listings = #tag.listings.includes(:user)
#users = #listings.map{|l| l.user}
else
#users = User.all
end
end

Better recursive loop in Ruby on Rails

Using Rails 3.2. Let's say I want 2 options:
Get all trip photos.
Get the first trip photo.
I have the following code:
# trip.rb
class Trip < ActiveRecord::Base
has_many :trip_days
def trip_photos
if (photos = trip_days.map(&:spots).flatten.map(&:photos).flatten.map)
photos.each do |photo|
photo.url(:picture_preview)
end
end
end
def trip_photo
trip_photos.first
end
end
# trip_day.rb
class TripDay < ActiveRecord::Base
belongs_to :trip
has_many :trip_day_spots
has_many :spots, :through => :trip_day_spots
end
# trip_day_spot.rb
class TripDaySpot < ActiveRecord::Base
belongs_to :trip_day
belongs_to :spot
end
#spot.rb
class Spot < ActiveRecord::Base
end
# trips_controller.rb
class TripsController < ApplicationController
def index
#trips = Trip.public.paginate(:page => params[:page], :per_page => 25)
end
end
As expected, the trip_photos method generates lots of SQL query. I wonder if there is any better way to do it?
It is because of N+1 queries. In this cases, we need to eager load all the associations of base object, so that when ever you call its associated object, it wont fire any queries for fetching them, simply it will get them from its cached object.
Hope this will work, but not tested. I assumed and wrote the following query.
def trip_photos
user_trip_days = trip_days.includes(:spots => :photos)
photos = user_trip_days.collect {|trip_day| trip_day.spots.map(&:photos).flatten}.flatten
photos.each do |photo|
photo.url(:picture_preview)
end if photos
end
Let me know if you get any errors.
For more info on eager loading associated objects in ActiveRecord, go through
Guides for Rails and Rails cast and Rails Tips
This might not be the most rails-y way, but if you truly wanted to get all the spots in one hit you could do something like:
def spots
Spot.joins("join trip_days_spots on spots.id = trip_days_spots.spot_id join trip_days on trip_days.id = trip_days_spots.trip_day_id join trips on trips.id = trip_days.trip_id").where("trips.id = ?", self.id)
end
then change your loop to:
def trip_photos
spots.map(&:photos).flatten.each do |photo|
photo.url(:picture_preview)
end
end
The code works fine, but to eager load, just add :include:
# trips_controller.rb
class TripsController < ApplicationController
def index
#trips = Trip.public.paginate(:include => [:trip_days => [:spots => :photos]], :page => params[:page], :per_page => 25)
end
end

How to handle removal of entries in a multi select check box are used

This is in continuation to the question which was raised here
How to add one-to-many objects to the parent object using ActiveRecord
class Foo < ActiveRecord::Base
has_many :foo_bars
end
class Bar < ActiveRecord::Base
end
class FooBar < ActiveRecord::Base
belongs_to :foo
belongs_to :bar
end
How to handle removal of entries in a multi select check box are used to represent one-to-many entities. I am able to add or update entries, but removal seems to fail since foo_id seems to be empty and the query seems to be updating instead of delete.
EDIT :
I tried with #charlysisto suggestion using the following code
My Controller code is as follows :
class Foo < ActiveRecord::Base
has_many :foo_bars
has_many :bars, :through => :foo_bars
end
def edit
#foo = Foo.find(params[:id])
#sites = Site.where(company_id: #current_user.company_id).all
end
def update
#foo = Foo.find(params[:id])
if #foo.update_attributes(params[:foo])
flash[:notice] = "Foo was successfully updated"
redirect_to foos_path
else
render :action => 'edit'
end
end
View code is as follows :
<% #bars.each do |bar| %>
<%= check_box_tag 'bar_ids[]', bar.id %>
<%= bar.name %>
<% end %>
So I tried with these changes, but still foo_bars doesn't seem to reflect the changes if I removed a record.
What's missing in your associations is this :
class Foo < ActiveRecord::Base
has_many :bars, :through => :foo_bars
end
... the has_many (or has_many :through) macro gives you a truck load of methods including bar_ids and bar_ids= [...]
So all you need to do in your views/controller is :
# edit.html.haml which will send an array of bar_ids
=f.select :bar_ids, Bar.all.map {|b| [b.name, b.id]}, {}, :multiple => true
# foo_controller
#foo.update_attributes(params[:foo])
And that's it! No need to manually set or delete the associations in FooController#update...

Resources