Reputation: 61
I have a WorkSpace model that has_many Reviews. The Reviews model has a list of attributes, each with their own average rating calculated. I'd like to allow users to find WorkSpaces with the highest rated attribute of their choice.
I have been able to achieve this using scopes and keeping the logic inside the WorkSpace model.
This is my first attempt to do any kind of logic in Rails and I'm wondering if this logic would be better in the controller instead. It works well, but the info it generates is attached to every WorkSpace and I'm thinking this a little redundant as the only time a user would need to access this data is when they're using the filtering system not every time they click on a WorkSpace.
WorkSpace Model(The half with the logic I'm discussing)
class WorkSpace < ApplicationRecord
belongs_to :user
has_many :reviews, dependent: :delete_all
scope :max_rating, ->(rating) { joins(:reviews)
.group('work_spaces.id')
.order('AVG(reviews.rating) desc')
.having('AVG(reviews.rating) > ?', rating) if rating }
scope :max_bathroom, ->(bathroom) { joins(:reviews)
.group('work_spaces.id')
.order('AVG(reviews.bathroom) desc')
.having('AVG(reviews.bathroom) > ?', bathroom) if bathroom }
scope :max_noise, ->(noise) { joins(:reviews)
.group('work_spaces.id')
.order('AVG(reviews.noise) desc')
.having('AVG(reviews.noise) > ?', noise) if noise }
scope :max_wifi, ->(wifi) { joins(:reviews)
.group('work_spaces.id')
.order('AVG(reviews.wifi) desc')
.having('AVG(reviews.wifi) > ?', wifi) if wifi }
scope :max_seating, ->(seating) { joins(:reviews)
.group('work_spaces.id')
.order('AVG(reviews.seating) desc')
.having('AVG(reviews.seating) > ?', seating) if seating }
def top_avg_rating
WorkSpace.max_rating(2).limit(5)
end
def top_avg_bathroom
WorkSpace.max_bathroom(2).limit(5)
end
def top_avg_noise
WorkSpace.max_noise(2).limit(5)
end
def top_avg_wifi
WorkSpace.max_wifi(2).limit(5)
end
def top_avg_seating
WorkSpace.max_seating(2).limit(5)
end
Review Model
# frozen_string_literal: true
class Review < ApplicationRecord
belongs_to :work_space
belongs_to :user
end
WorkSpace Serializer
class WorkSpaceSerializer < ActiveModel::Serializer
attributes :id,
:place_id,
:lat,
:lng,
:name,
:address,
:photo,
:reviews,
:user,
:count_reviews,
:avg_rating,
:avg_noise,
:avg_wifi,
:avg_bathroom,
:avg_food,
:avg_coffee,
:avg_seating,
:avg_outlet,
:bool_outlet,
:bool_seating,
:bool_coffee,
:bool_food,
:bool_bathroom,
:bool_wifi,
:top_avg_rating,
:top_avg_bathroom,
:top_avg_noise,
:top_avg_wifi,
:top_avg_seating
has_one :user
has_many :reviews
end
Could this or should this type of logic be done in the WorkSpace controller instead? And only accessed when an Axios GET request is made? Or... am I way off base and I should just give up now?
Update
So far I was able to Dry up the scopes with this bit of code.
scope :by_average_for, ->(column) {
joins(:reviews)
.group('work_spaces.id')
.order("AVG(reviews.#{column}) desc")
.having("AVG(reviews.#{column}) > 4", column) if column
}
Thank you, https://stackoverflow.com/users/14660/schwern.
I'm working on implementing the class method next. Can't seem to get that working...
Upvotes: 3
Views: 332
Reputation: 165456
Generally, controllers are "skinny". They should only contain logic which connects models to views and that's it. Database logic goes into models. Display logic goes into decorators. Talking to APIs and services goes into service objects.
Your logic can be DRY'd up. Your scopes can be turned into a single scope that takes an argument.
scope :by_average_for, ->(column) {
joins(:reviews)
.group('work_spaces.id')
.order("AVG(reviews.#{column})", :desc)
}
Similarly, a single class method can replace all the top_foo
methods. These can be made more flexible by taking arguments with defaults.
class << self
def top_averages_for(column, greater_than: 2, limit: 5)
by_average_for(column)
.having("AVG(reviews.#{column}) > ?", greater_than)
.limit(5)
end
end
If you need individual instance methods for serialization, they can also be DRY'd up by defining the methods dynamically using define_method.
TOP_AVG_COLUMNS = [
:rating,
:seating,
...
].freeze
TOP_AVG_COLUMNS.each do |column|
define_method(:"top_avg_#{column}") do
top_averages_for(column)
end
end
If these are only for serialization, they may be more appropriate in a decorator.
You can use TOP_AVG_COLUMNS
and similar constants to DRY up your list of attributes to serialize.
# In WorkSpace
TOP_AVG_ATTRIBUTES = TOP_AVG_COLUMNS.map { |col|
:"top_avg_#{col}"
}.freeze
# In WorkSpaceSerializer
ATTRIBUTE_COLUMNS = [
:id,
:place_id,
:lat,
:lng,
:name,
:address,
:photo,
:reviews,
:user
].freeze
attributes(ATTRIBUTE_COLUMNS + WorkSpace::TOP_AVG_ATTRIBUTES)
If this business logic got more complicated your model might get fat. Then you'd move it into a WorkSpaceManager, an ActiveModel::Model, whose purpose is to perform business logic on WorkSpaces.
Upvotes: 2