Introduction
Ruby setter methods allow us to provide a custom implementation for
=
-assignments to object attributes. For example
class Foo
attr_reader :name
def initialize
@name = "default"
end
def name=(value)
@name = value.downcase
end
end
These methods are a very useful tool and can be used to great effect in making the client code look and feel simpler:
foo = new Foo()
foo.name # default
foo.name = "UPDATED"
foo.name # updated
The Context
The lesson is not specific to Rails, but because I hit the problem in a Rails project I will use this as the context for the following discussion.
We will start with very basic User
and Profile
models, wherein a User
has one associated
Profile
. The Profile
has a single attribute of name
:
class User < ApplicationRecord
has_one :profile
…
def set_profile(name)
self.profile = Profile.create(name: name)
self.profile
end
…
end
The set_profile
method creates an Profile
record, sets the
association and returns the record just created.
This works fine and somewhere in a controller we would have called the method, thusly:
class ProfilesController < ApplicationController
def create
@user = get_user(params[:user_id])
render json: @user.set_profile(params[:profile_name])
end
end
The controller method creates a new Profile
record, assigns it to
the @user.profile
association and returns a JSON representation of the
Profile
just created.
The Problem
In a future iteration we need to update a User
, along with the associated
Profile
, from the UsersController
:
class UsersController < ApplicationController
def update
@user = get_user(params[:id])
@user.update(user_params)
@user.set_profile(params[:profile_name]) # Can we get rid of this using a setter method??
end
def user_params
params.require(:user).permit(… :profile_name)
end
end
In seeing this code we would like to have a single update statement that is able to
handle all of the params that we throw at it, including the profile_name
.
We see what looks like a simple solution, by simply updating the User
model like so:
class User < ApplicationRecord
has_one :profile
…
def profile_name=(name)
self.profile = Profile.create(name: name)
self.profile
end
…
end
where we have just replaced the method name :set_profile
to be a
setter method: :profile_name=
.
With this change the update
action in the UsersController
simplifies as we had intended:
def update
@user = get_user(params[:user_id])
@user.update(user_params)
# Line below is no longer necessary, as update method will now also set profile association
# @user.set_profile(params[:profile_name])
end
But we will obviously need to update the ProfilesController#create
which also relies
on the method we have just altered:
class ProfilesController < ApplicationController
def update
@user = get_user(params[:user_id])
render json: @user.profile_name=(params[:profile_name]) # Here is the problem
end
end
And herein lies the problem. Our :set_profile
method was constructed to return
the Profile
instance created, and the client code relied on this fact.
The method body is still the exact same as before, but the setter method will always return
the value passed to it, i.e. the String parameter, params[:profile_name]
.
Whilst this caught me out, it is the expected behaviour and is stated clearly in the
ruby docs:
Note that for assignment methods the return value will be ignored when using the assignment syntax. Instead, the argument will be returned:def a=(value) return 1 + value end p(self.a = 5) # prints 5
We can resolve the issue simply in one of two ways, but perhaps only after a bit of head-scratching about why this innocuous search-and-replace on a method name has caused a bunch of test failures!
First, we could just set the
@user.profile_name
in one line, and retrieve the newly created
Profile
right after:
class ProfilesController < ApplicationController
def update
@user = get_user(params[:user_id])
@user.profile_name = params[:profile_name]
render json: @user.profile
end
end
Alternatively, we can use :send
to call the setter method directly, and
ensure the explicit return statement is honoured. But, personally, I think this kind of
undermines the readability motivation for using the setter method in the first place:
class ProfilesController < ApplicationController
def update
@user = get_user(params[:user_id])
render json: @user.send(:profile_name=, params[:profile_name])
end
end
The Lesson
Not so much a lesson here, just a warning. If you are switching an existing method to become a setter method, be careful that you are not relying on a return value different from the value passed into the setter. The setter method will always return the value that has been passed into it.
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …