loading...

TIL You Can Add Virtual Attributes to Rails Models

andy profile image Andy Zhao (he/him) Updated on ・4 min read

Disclaimer: I learned about it yesterday. Woops.

I was building out an email sending feature, and the basic idea was to assign a role to a user that has an expiration date, and also allow the option to send an email letting the user know. The end result is this:

Example image of role adding form

Building out the form was pretty straightforward and easy, especially since most of the work was done for me already with the Administrate gem. There was even a nice wiki that told me how to make a custom form field, and it even includes a command line generator! Great! This is all so easy! Well, like most problems, this one was not as simple as I thought.

The generator creates a view partial, and after a bit of configuration, all I had to do was add a few more fields:

<div class="field-unit__label">
  <%= f.label field.attribute %> #=> Give superpowers?
</div>
<div class="field-unit__field" style="width: 2em;">
  <%= f.check_box field.attribute %> #=> User.add_role superpowered
</div>
<%= f.date_field "superpower_expiration", 
    value: (Time.now + 1.year).strftime("%Y-%m-%d"), style: "width: 25%;" %>
  <div style="margin-left: 1em;">
    <%= f.label "Send email telling them they have it?" %>
  </div>
  <div style="margin-left: 1em;">
    <%= check_box_tag "superpower_email" %>
  </div>
View partial or partial view? You decide.

Okay, that's all I have to do right? The form submitted, everything works, bada bing bada boom push that code to production. Well, once the code gets to production, I realize it doesn't work -- actually, Jess realizes it doesn't work, and let's me know I goofed. Lesson learned. Always check your code, and see...



<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">

What happens to it after you deploy it.

It's not enough to compile and pass tests and not get paged about it. You should have muscle memories for going to check up on it -- did what you expected to happen, actually happen? Did anything else change?



So, back to the code. Here's what I thought was the offending code:

# Controller
  class UsersController < ApplicationController
    def update
      user = User.find(params[:id])
      UserRoleService.new(user).check_for_roles(params[:user])
      if user.errors.messages.blank? && user.update(user_params)
        # happy path
      else
        # sad path
      end
    end

   private

   def strong_params
     accessible = [user, model, attributes]
     params.require(:user).permit(accessible)
   end
  end

# user_role_service.rb, the service that handles new roles
  class UserRoleService
    def check_for_roles
      # some other logic
      NotifyMailer.delay.superpowers_awarded_email(@user) if params[:superpower_email] == "1"
    end
  end

Ahh, I forgot that params[:superpower_email] is a normal parameter and not permitted. I tried to push it into the accessible array, but the :superpower_email param was still nowhere to be found. Well, since it didn't work, I had to change the checkbox field:

  <div style="margin-left: 1em;">
    <%= f.check_box "superpower_email" %>
  </div>

But that threw a NoMethodError, undefined method for User. Well, the superpower_email wasn't supposed to be an actual column in the model, and that would be ridiculous if I had to create a migration and make that column. There had to be a better way. I asked for some help, and Ben said:

Ben's response: virtual attributes

I replied with :explodingheademoji:, and yeah, it's a real emoji.

So now that you've read so much of this story, the question finally arises: what is a virtual attribute? Well, after some Googling, I figured it out.

Rails models are classes that inherited from ApplicationRecord, and just like classes we can add attributes to them. Almost always, model attributes link with a database column, since the model is essentially the table you're interacting with. There are some cases though where you wouldn't need to link an attribute to the database, and therefore you can create the attribute just like you would with any Ruby class. This was the big "Aha!" moment for me.

Now, I could have went with the simplest way of creating the class attribute:

class User < ApplicationRecord

  def superpower_email; end

end

But that didn't seem quite right. After all, I didn't really need a method to run any logic. So, I turned to this solution instead:

class User < ApplicationRecord
  attr_acccessor :superpower_email
end

The reason I needed it to be an accessor attribute as opposed to only a reader or only a writer was so that the form could both render the attribute properly as well as pass along the data as a parameter. Also, this would make it easier to add future extraneous attributes later on if I ever needed them.

Update: As @kinduff mentioned in the comments, it might be misleading about the difference between accessor and reader + writer. accessor is synonymous and an alias of using both attr_reader and attr_writer.

So I go ahead and add the attribute into the User model (class), add the new attribute as an allowed strong parameter, check that an email is queued as a job, and it works! Huzzah! Back into production the code goes!

Thanks for reading this long TIL. :)

Reference:

  1. Go Rails Episode and Transcript - Virtual Attributes and Rails 5 Attributes API

Discussion

pic
Editor guide
 

ooooh i got a shout out 😍

 

I don't have all the context of your application but they way I understood, if the role is not updated for some reason, the user will get notified that they have a super power when they not. You should move the check after the role is updated.

Also it's worth the mention that attr_acccessor (Ruby method apidock.com/ruby/Module/attr_accessor) acts as an alias for attr_reader and attr_writer methods, both options will work for this case.

Examples:

attr_writer :superpower_email

# generates
def superpower_email=(value);
  @superpower_email = value
end

# -
attr_reader :superpower_email

# generates
def superpower_email
  @superpower_email
end

# -
attr_accessor :superpower_email

# generates
def superpower_email=(value)
  @superpower_email = value
end

def superpower_email
  @superpower_email
end

Hope this makes it clear, since the line about why you use attr_accessor can be misleading.

Good writing, thanks for sharing.

 

Gotcha, thanks for that. I updated my article to clarify my point.

And yeah, good point about the email sending even if the role is not properly updated.

Thanks for reading!

 

There is a typo mistake.

  attr_acccessor superpower_email

should be

  attr_accessor :superpower_email