The nVisium Blog

Using the Rails 5 Attributes API Today, in Rails 4.2

In 2014, I gave a talk at RailsConf that touched on some problems I've come to experience with the opinions that Rails employs to achieve its "convenient" APIs. One part of the talk described something I really wanted to see, which was a sane attribute API. Well, Sean Griffin started working on one not long after that talk. It'll be "official" in Rails 5, but it's in Rails 4.2 and is already being used under the hood by your apps today—it's just missing documentation.

If you've ever tried doing something as simple as overriding attribute accessors and found yourself chasing down and plugging endless edge cases, this post is for you.

The Problem

Let's say we have a domain object in our app that doesn't descend from ActiveRecord::Base (the horror!), and we'd like an attribute on an ActiveRecord model to map to an object of this type. Our first thought might be to abuse serialize for these objects as they come out of the DB (often in JSON format) and override the setter to handle the cases in which attributes are being assigned by the app itself. That would look something like this:

class MyModel < ActiveRecord::Base
  serialize :my_attribute, MySerializer # responds to load and dump methods

  def my_attribute=(value)
    value = MyAttribute.new(value)
    super
  end
end

This seems to work at first, but then we realize that attributes can be set via write_attribute (you know this as its less verbose alias, []=) as well, so we need to add:

class MyModel < ActiveRecord::Base
  # ...
  def write_attribute(name, value)
    if name == :my_attribute
      value = MyAttribute.new(value)
    end
    super
  end
end

We think we've fixed it, but then we find ourselves needing to deal with the way that serialized columns are always treated as dirty (and therefore sent to the database), the cases in which our DB adapter sends us an already-deserialized JSON object instead of a string, and various other internals that we never ever wanted to know about just to define a simple attribute.

This is where the ActiveRecord Attributes API comes in.

Let's Get Specific

For the rest of this post, let's define our use case as follows:

  1. We have an ActiveRecord model that has an an attribute representing a list of links. Each link has a title and a URL.
  2. We don't need to maintain these links in their own table. They don't need to be referenced by multiple instances of the parent ActiveRecord model. They are just a bit of extra data we attach to our model.
  3. We'd like the extra data on this attribute to be consistently represented by a domain object in our app.
  4. We're using a PostgreSQL jsonb column to store our data. This could also work with MySQL, but you want to be using PostgreSQL, don't you?

For our domain objects, we'll have a Link class and a LinkList class. A minimal example Link class might look like this:

# app/models/link.rb
class Link
  attr_reader :title, :url

  def initialize(title:, url:)
    @title, @url = title.strip, url.strip
  end

  def empty?
    title.empty? || url.empty?
  end
end

def Link(attrs_or_link)
  case attrs_or_link
  when Link
    attrs_or_link
  else
    attrs = attrs_or_link
    Link.new(
      title: attrs.fetch(:title) { attrs.fetch('title') },
      url: attrs.fetch(:url) { attrs.fetch('url') }
    )
  end
end

I very much like to follow the pattern of methods like Array() and String() for casting to my domain objects, which is why you see the method definition outside the class body, above.

Now, let's look at the LinkList class:

# app/models/link_list.rb
class LinkList
  extend Forwardable
  attr_reader :links
  def_delegators :links, :empty?, :as_json, :[], :each, :size

  def initialize(array_or_hash = [])
    links = case array_or_hash
            when Hash
              [Link(array_or_hash)]
            else
              Array(array_or_hash).map { |link| Link(link) }
            end
    @links = links.reject(&:empty?)
  end

  def to_a
    links
  end
  alias :to_ary :to_a
end

def LinkList(value)
  LinkList === value ? value : LinkList.new(value)
end

For this simple case, we'll throw out any links that don't have both required attributes set in our list.

With the domain objects created, it's time to tell ActiveRecord about our attribute:

class Post < ActiveRecord::Base
  attribute :links, LinkList::Type.new
end

That one line is all that ActiveRecord needs to do the right thing with our attribute. The only problem is that there's no such thing as a LinkList::Type yet. We'll need to create one.

Thankfully, ActiveRecord's PostgreSQL adapter comes with a type for json attributes that is very close to what we need. You can view its source here.

We're going to use this code with only minor updates.

  1. For simplicity's sake, we aren't going to treat LinkList as a mutable type.
  2. We will define the type as :jsonb to match the column type in the database.
  3. We will add casting to LinkList after we have handled the JSON deserialization and add LinkList as a type we will serialize.

Here's what it looks like:

class LinkList
  # ...
  class Type < ActiveRecord::Type::Value
    def type
      :jsonb
    end

    def type_cast_from_user(value)
      LinkList(value)
    end

    def type_cast_from_database(value)
      if String === value
        decoded = ::ActiveSupport::JSON.decode(value) rescue nil
        LinkList(decoded)
      else
        super
      end
    end

    def type_cast_for_database(value)
      case value
      when Array, Hash, LinkList
        ::ActiveSupport::JSON.encode(value)
      else
        super
      end
    end
  end
end

A few quick notes about this code:

  • I normally dislike inner classes. This is one of the few cases in which I think they make a lot of sense.
  • type_cast_from_user is what is called when your app sets the attribute. In Rails 5, this method will be renamed to simply cast.
  • type_cast_from_database receives the serialized data from the database and returns a live object. This one will be aptly named deserialize in Rails 5.
  • type_cast_for_database serializes the data for the database. You may have already guessed, but it'll be called serialize in Rails 5.
  • If we'd implemented a mutable domain object, we'd also need to implement changed_in_place? and proper equality tests for our objects, so that we could decide whether we need to write the attribute to the database or not.

Tying It All Together

At this point, you might be wondering how we'll actually set this attribute in our app. If you're posting JSON to the controller, it couldn't be easier. Assuming the model is Post as above, we'll just POST (or PATCH) a body like the following:

{
  "_comment": "Pretend there are other attributes here as applicable.",
  "post": {
    "links": [
      { "title": "nVisium Homepage", "url": "https://nvisium.com" },
      { "title": "nVisium Blog", "url": "https://blog.nvisium.com" }
    ]
  }
}

If you want to create a form, it'll be a little more involved, but not too bad:

<%= form_for @post do |f| %>
  <!-- Let's pretend there are other fields here, shall we? -->
  <% @post.links.each do |link| %>
    <%= f.fields_for 'links[]', link, index: nil do |l| %>
      <%= l.text_field :title %>
      <%= l.text_field :url %>
    <% end %>
  <% end %>
  <!-- A placeholder for adding a new link. Bring your own JavaScript. ;) -->
  <%= f.fields_for 'links[]', Link.new(title: '', url: ''), index: nil do |l| %>    <%= l.text_field :title %>
    <%= l.text_field :url %>
  <% end %>
<% end %>

And that's it! I hope that armed with this knowledge, you'll use domain objects more freely in your Rails apps instead of treating your ActiveRecord models as big bags of data.

I'd like to thank Sean Griffin for taking the time to write this feature and get it pushed into Rails. It's one thing to talk about a better API, but another thing entirely to put the effort into writing it. Thanks, Sean!