"We want users to be able to upvote and downvote both books and reviews. How would you approach this?"
- Single
votestable with polymorphic association Votemodel withvotable_typeandvotable_idVotableconcern to DRY up shared logic- Single controller handling both votable types
- Reusable view partial with
polymorphic_path
# db/migrate/xxx_create_votes.rb
class CreateVotes < ActiveRecord::Migration[7.1]
def change
create_table :votes do |t|
t.references :user, null: false, foreign_key: true
t.references :votable, polymorphic: true, null: false
t.integer :value, null: false
t.timestamps
end
add_index :votes, [:user_id, :votable_type, :votable_id], unique: true
add_index :votes, [:votable_type, :votable_id]
end
endLook for:
polymorphic: trueon references- Unique composite index includes
votable_type - Second index for querying votes by votable
# app/models/vote.rb
class Vote < ApplicationRecord
belongs_to :user
belongs_to :votable, polymorphic: true
validates :value, inclusion: { in: [1, -1] }
validates :votable_id, uniqueness: { scope: [:user_id, :votable_type] }
scope :upvotes, -> { where(value: 1) }
scope :downvotes, -> { where(value: -1) }
end# app/models/concerns/votable.rb
module Votable
extend ActiveSupport::Concern
included do
has_many :votes, as: :votable, dependent: :destroy
end
def score
votes.sum(:value)
end
def vote_from(user)
return nil unless user
votes.find_by(user: user)
end
end# app/models/review.rb (add)
include Votable# app/models/book.rb (add)
include Votable# app/models/user.rb (add)
has_many :votes, dependent: :destroyLook for:
- Concern extraction (not duplicating methods in both models)
- Polymorphic
belongs_toandhas_many :as - Uniqueness scoped to both
user_idANDvotable_type
# config/routes.rb (add)
concern :votable do
resource :vote, only: [:create, :destroy]
end
resources :books, concerns: :votable
resources :reviews, concerns: :votableGenerates:
POST /books/:book_id/voteDELETE /books/:book_id/votePOST /reviews/:review_id/voteDELETE /reviews/:review_id/vote
Alternative (also acceptable):
resources :books do
resource :vote, only: [:create, :destroy]
end
resources :reviews do
resource :vote, only: [:create, :destroy]
endLook for: Singular resource :vote, routes for both models.
# app/controllers/votes_controller.rb
class VotesController < ApplicationController
before_action :require_authenticated_user
before_action :set_votable
def create
value = params[:value].to_i
unless [1, -1].include?(value)
return redirect_back fallback_location: @votable, alert: "Invalid vote"
end
vote = current_user.votes.find_or_initialize_by(votable: @votable)
vote.value = value
if vote.save
redirect_back fallback_location: @votable
else
redirect_back fallback_location: @votable, alert: "Could not save vote"
end
end
def destroy
vote = current_user.votes.find_by(votable: @votable)
vote&.destroy
redirect_back fallback_location: @votable
end
private
VOTABLE_TYPES = {
'book_id' => Book,
'review_id' => Review
}.freeze
def set_votable
VOTABLE_TYPES.each do |param, klass|
if params[param]
@votable = klass.find(params[param])
return
end
end
raise ActiveRecord::RecordNotFound
end
endLook for:
- Single controller for all votable types
- Clean votable lookup pattern
find_or_initialize_byfor upsert behavior- Value validation (allowlist)
Alternative set_votable (also acceptable):
def set_votable
if params[:book_id]
@votable = Book.find(params[:book_id])
elsif params[:review_id]
@votable = Review.find(params[:review_id])
else
raise ActiveRecord::RecordNotFound
end
end<%# app/views/votes/_controls.html.erb %>
<%# locals: (votable:) %>
<div class="vote-controls">
<span class="score"><%= votable.score %></span>
<% if current_user %>
<% current_vote = votable.vote_from(current_user) %>
<% path = polymorphic_path([votable, :vote]) %>
<%= button_to path,
params: { value: 1 },
method: :post,
class: current_vote&.value == 1 ? "btn-voted" : "btn-vote" do %>
▲
<% end %>
<%= button_to path,
params: { value: -1 },
method: :post,
class: current_vote&.value == -1 ? "btn-voted" : "btn-vote" do %>
▼
<% end %>
<% if current_vote %>
<%= button_to "Clear", path, method: :delete, class: "btn-clear" %>
<% end %>
<% end %>
</div>Usage:
<%# books/show.html.erb %>
<%= render "votes/controls", votable: @book %>
<%# reviews/_review.html.erb %>
<%= render "votes/controls", votable: review %>Look for:
polymorphic_path([votable, :vote])- generates correct URL for any votable- Single reusable partial
- Vote state indication
| If stuck on... | Hint |
|---|---|
| Polymorphic syntax | "Rails has a polymorphic: true option on references" |
| Single controller for both | "What if the controller didn't care which type it was voting on?" |
| Finding the votable | "How do you know if it's a book or review from the params?" |
| Reusable view partial | "Rails has polymorphic_path for generating URLs" |
| Concern extraction | "Both models need the same methods - where could those live?" |
When complete, they should have created/modified:
app/
├── controllers/
│ └── votes_controller.rb (new)
├── models/
│ ├── concerns/
│ │ └── votable.rb (new)
│ ├── vote.rb (new)
│ ├── book.rb (add: include Votable)
│ └── review.rb (add: include Votable)
└── views/
└── votes/
└── _controls.html.erb (new)
config/
└── routes.rb (modified)
db/migrate/
└── xxx_create_votes.rb (new)