It’s common to use the decorator/presenter pattern to wrap ActiveRecord
objects and add view-specific logic.
For example:
class Profile < SimpleDelegator
def title
"#{first_name} #{last_name}'s profile"
end
end
In the controller, you’d say something like:
class ProfilesController < ApplicationController
def show
@profile = Profile.new(current_user)
end
end
And in the view:
<h1><%= profile.title %></h1>
Things fall apart
Simple enough. Now let’s look at some examples that leverage
to_partial_path
.
Given this decorator:
class Profile < SimpleDelegator
def to_partial_path
"profiles/profile"
end
end
What happens in this view?
<%= render profile %>
It will not render the profiles/profile
partial but will instead render
the users/user
partial. What happened?
Becoming a model
The Rails rendering code has a conditional that looks like this (simplified):
if object.responds_to?(:to_model)
path = object.to_model.to_partial_path
else
path = object.to_partial_path
end
For most objects where you define to_partial_path
, the rendering code will
take that second branch and just call to_partial_path
on your object.
However, decorated ActiveRecord
objects do respond to to_model
so the
rendering will take that first branch. What happens when you call to_model
on
a decorator?
It delegates to the wrapped user’s implementation which returns itself. This
means calling to_model
on the decorator returns the unwrapped user.
[1] pry(main)> profile = Profile.new(User.new)
=> #<User:0x007f82da2e1d28>
[2] pry(main)> profile.class
=> Profile
[3] pry(main)> profile.to_model.class
=> User
This all means that User#to_partial_path
gets invoke rather than
Profile#to_partial_path
.
The solution is to define to_model
on the decorator:
class Profile < SimpleDelegator
def to_model
self
end
# other things
end
A matter of form
So all you need to do is define to_model
on your decorator? Not so fast! Rails
has some other surprises up its sleeve.
How about something like this:
<%= form_for Profile.new(User.new) do |f| %>
<% end %>
You might expect this form to POST to /profiles
but instead it will submit
to /users
. Isn’t the path based on the class name? Apparently not. Instead the
URL is built based on attributes of your object’s model_name
. Since
model_name
is delegated to the underlying user, the form submits to /users
.
[1] pry(main)> profile = Profile.new(User.new)
=> #<User:0x007fc678898780>
[2] pry(main)> profile.model_name.route_key
=> "users"
This can be fixed by extending ActiveModel::Naming
(which defines
model_name
):
class Profile < SimpleDelegator
extend ActiveModel::Naming
end
Now it’s generating paths for “profiles” instead of “users”.
[7] pry(main)> profile = Profile.new(User.new)
=> #<User:0x007fc6805005e8>
[9] pry(main)> profile.model_name.route_key
=> "profiles"
Playing nicely with ActiveRecord and ActiveModel
If you’re decorating an ActiveRecord
or ActiveModel
object in Rails, you
probably want to define the following to ensure the decorator works the way you
expect instead of silently delegating to the underlying object:
class Profile < SimpleDelegator
extend ActiveModel::Naming
def to_model
self
end
end