Reduce N+1 queries in serialisation with assosiations - ruby-on-rails

Models:
class Audio < ActiveRecord::Base
has_many :tests, as: :item
end
class Video < ActiveRecord::Base
has_many :tests, as: :item
end
class Test < ActiveRecord::Base
belongs_to :user
belongs_to :item, polymorphic: true
end
class User < ActiveRecord::Base
has_many :tests
def score_for(item)
return 0 unless tests.where(item: item).any?
tests.where(item: item).last.score
end
end
Serializers:
class VideoSerializer < ActiveModel::Serializer
attributes :id, :name
attribute(:score) { user.score_for(object) }
def user
instance_options[:user]
end
end
I try serialise lot of Video objects like this, but N+1 coming:
options = { each_serializer: VideoSerializer, user: User.last }
videos = ActiveModelSerializers::SerializableResource.new(Video.all, options).serializable_hash
If I try this, empty array returned(looks like videos not has tests for this user):
options = { each_serializer: VideoSerializer, user: User.last }
videos = ActiveModelSerializers::SerializableResource.new(Video.includes(:tests).where(tests: {user: User.last}), options).serializable_hash
How I can organise serialisation w/o N+1 queries problem.

You cannot avoid an N+1 query if you are using a method that triggers another SQL query (in this case where).
The method score_for does another query (or 2, which would definitely need refactoring) when you invoke the relation with where.
One way you could change this method would be not to use relation methods but array methods over already loaded relations. This is very inefficient for memory but much less heavy on DB.
def score_for(item)
tests.sort_by&:created_at).reverse.find { |test| test.user_id == id }&.score.to_f
end
You would need to load the video with its tests and the user.

Related

Can Rails have dynamic relationships in has_many?

I have a Lesson model which has many Completions like this:
class Lesson < ActiveRecord::Base
has_many :completions, as: :completable
belongs_to :course
end
And each Completion belongs to a User as well:
class Completion < ActiveRecord::Base
belongs_to :user
belongs_to :completable, polymorphic: true
end
From my application perspective I'm only interested in the amount of completions for a certain lesson, so I've included a counter cache. In regard to the individual Completions, I'm only interested if the Lesson is completed by the current user (I'm using Devise).
Is there some way to create a dynamic has_one relationship of some kind, that uses the information from the current_user to query the Completion table?
for instance:
has_one :completion do
def from_user current_user
Completion.where(completable: self, user: current_user)
end
end
Although this could work, I'm also having a polymorphic relationship. Rails is complaining that there's no foreign key called lesson_id. When I add a foreign_key: symbol, the do-end block stops working.
Any ideas?
Why not passing both block and options to has_many?
class Lesson < ActiveRecord::Base
has_many :completions, as: :completable do
def from_user user
if loaded?
find {|c| c.user_id = user.id}
else
find(user_id: user.id)
end
end
end
belongs_to :course
end
#lesson = Lesson.last
# Association not loaded - executing sql query
#lesson.completions.from_user(current_user)
#lesson.completions
# Association loaded - no sql query
#lesson.completions.from_user(current_user)
NOTE: You cannot treat it as an association, so it cannot be preloaded on its own.

Simple scopes and association

So I'm new to scopes, and don't really understand them well.Let's say I have 2 models, Project and Ticket:
class Project < ActiveRecord::Base
has_many :tickets
end
class Ticket < ActiveRecord::Base
belongs_to :project
end
I'm used to code like this to access associated data from tickets:
Project.find(1).tickets.each do |ticket|
puts ticket.name
end
I created new scope:
scope :default, -> { where(default: true) }
And now when I use Project.default I get back ActiveRecord::Relation and don't have a clue how to access associated tickets ?
Project.default will indeed return an ActiveRecord::Relation, which is a 'to be triggered' query. The query will be triggered once you start looping etc, this is sort of transparent to you.
If you want to get tickets from the projects, first I recommend you include them in your query to avoid N+1. Do it this way:
projects = Project.default.includes(:tickets)
Then to access tickets of a particular project:
project = projects.first
project.tickets
If you want a method to always return a single object:
class Project < ActiveRecord::Base
has_many :tickets
def self.get_default_with_tickets
Project.where(default: true).includes(:tickets).first
end
end
then:
Project.get_default_with_tickets #=> your_project
Be sure to handle the cases:
when there is more than one match
when there is no match
A scope is basically just a class method (one which fires on a non-intialized model):
class Project < ActiveRecord::Base
has_many :tickets
scope :defaults, -> { where(default: true) }
end
This means if you do this:
#defaults = Project.defaults
... you get all the project objects back which have the attribute default as true
This is the same as this:
class Project < ActiveRecord::Base
has_many :tickets
def self.defaults
where(default: true)
end
end
Error
The reason you're getting a relation is because when you use where, you're basically getting back an "array" of data (as opposed to just a single record). If you .each through the data or just return .first, you'll get an actual object which you can output:
#defaults = Project.defaults
#defaults.each do |project|
project.tickets #-> associated tickets
end
Use all or first to complete the query.
#get all default projects
Project.default.all
#get the first default project
Project.default.first

Association not working

I have three models:
Department
class Department < ActiveRecord::Base
has_many :patients, :dependent => :destroy
has_many :waitingrooms, :dependent => :destroy
end
Waitingroom with fields patient_id:integer and department_id:integer
class Waitingroom < ActiveRecord::Base
belongs_to :patient
end
Patient with department_id:integer
class Patient < ActiveRecord::Base
belongs_to :department
has_many :waitingrooms
end
I save a waitingroom after a patient was in the waitingroom! So now i tried to retrieve the patients who where in the the waitingroom of the department:
def index
#waited = #current_department.waitingrooms.patients
end
Somehow it didnt worked it returned this error:
undefined method `patients' for #<ActiveRecord::Associations::CollectionProxy::ActiveRecord_Associations_CollectionProxy_Waitingroom:0x374c658>
But this worked: What did i wrong? Thanks!
def index
#waited = #current_department.waitingrooms
end
You can't invoke an association on a collection. You need to invoke it on a specific record. If you want to get all the patients for a set of waiting rooms, you need to do this:
def index
rooms = #current_department.waitingrooms
#waited = rooms.map { |r| r.patients }
end
If you want a flat array, you could (as a naive first pass) use rooms.map { |r| r.patients }.flatten.uniq. A better attempt would just build a list of patient ids and fetch patients once:
#waited = Patient.where(id: rooms.pluck(:patient_id).uniq)

Active Relation: Retrieving records through an association?

I have the following models:
class User < ActiveRecord::Base
has_many :survey_takings
end
class SurveyTaking < ActiveRecord::Base
belongs_to :survey
def self.surveys_taken # must return surveys, not survey_takings
where(:state => 'completed').map(&:survey)
end
def self.last_survey_taken
surveys_taken.maximum(:position) # that's Survey#position
end
end
The goal is to be able to call #user.survey_takings.last_survey_taken from a controller. (That's contrived, but go with it; the general goal is to be able to call class methods on #user.survey_takings that can use relations on the associated surveys.)
In its current form, this code won't work; surveys_taken collapses the ActiveRelation into an array when I call .map(&:survey). Is there some way to instead return a relation for all the joined surveys? I can't just do this:
def self.surveys_taken
Survey.join(:survey_takings).where("survey_takings.state = 'completed'")
end
because #user.survey_takings.surveys_taken would join all the completed survey_takings, not just the completed survey_takings for #user.
I guess what I want is the equivalent of
class User < ActiveRecord::Base
has_many :survey_takings
has_many :surveys_taken, :through => :survey_takings, :source => :surveys
end
but I can't access that surveys_taken association from SurveyTaking.last_survey_taken.
If I'm understanding correctly you want to find completed surveys by a certain user? If so you can do:
Survey.join(:survey_takings).where("survey_takings.state = 'completed'", :user => #user)
Also it looks like instead of:
def self.surveys_taken
where(:state => 'completed').map(&:survey)
end
You may want to use scopes:
scope :surveys_taken, where(:state => 'completed')
I think what I'm looking for is this:
class SurveyTaking < ActiveRecord::Base
def self.surveys_taken
Survey.joins(:survey_takings).where("survey_takings.state = 'completed'").merge(self.scoped)
end
end
This way, SurveyTaking.surveys_taken returns surveys taken by anyone, but #user.survey_takings.surveys_taken returns surveys taken by #user. The key is merge(self.scoped).
Waiting for further comments before I accept..

Rails: order using a has_many/belongs_to relationship

I was wondering if it was possible to use the find method to order the results based on a class's has_many relationship with another class. e.g.
# has the columns id, name
class Dog < ActiveRecord::Base
has_many :dog_tags
end
# has the columns id, color, dog_id
class DogTags < ActiveRecord::Base
belongs_to :dog
end
and I would like to do something like this:
#result = DogTag.find(:all, :order => dog.name)
thank you.
In Rails 4 it should be done this way:
#result = DogTag.joins(:dog).order('dogs.name')
or with scope:
class DogTags < ActiveRecord::Base
belongs_to :dog
scope :ordered_by_dog_name, -> { joins(:dog).order('dogs.name') }
end
#result = DogTags.ordered_by_dog_name
The second is easier to mock in tests as controller doesn't have to know about model details.
You need to join the related table to the request.
#result = DogTag.find(:all, :joins => :dog, :order => 'dogs.name')
Note that dogs is plural in the :order statement.

Resources