August 29, 2010

Posted by John

Tagged mongomapper and ruby

Older: August 3rd

Newer: Stop Googling

Building an Object Mapper: Override-able Accessors

There are several things I have learned building object mappers that I now take for granted. Last week while pairing with Jason, I was explaining a trick and he said I should blog about it, so here goes nothing.

Let’s say you are building a new object mapper named TacoMapper. A sensible place to start is with attribute accessors. One other thing to note is that we don’t care about old news, so we won’t support Rails 2 in any fashion. Deciding this, we can take advantage of ActiveModel and new features in ActiveSupport.

First Goal

First, let’s think about API. Our first goal will be to make the following work:

class User
  include TacoMapper
  attribute :email
end

user = User.new
user.email = 'John@Doe.com'
puts user.email # "John@Doe.com"

First Solution

The first thing we need is a class method named attribute.

require 'active_model'
require 'active_support/all'

module TacoMapper
  extend ActiveSupport::Concern

  module ClassMethods
    def attribute(name)
      attr_accessor(name)
    end
  end
end

class User
  include TacoMapper
  attribute :email
end

user = User.new
user.email = 'John@Doe.com'
puts user.email # "John@Doe.com"

ActiveSupport::Concern is a handy little ditty that does a lot out of the box. In our case, it will automatically call extend(ClassMethods) and add our attribute class method whenever our TacoMapper module gets included. With just a tiny bit of code, we have met our first goal.

Second Goal

Now, I am going to throw a kink in the mix. The goal of this post is to create override-able accessors. Let’s say we want to override the email writer method and make sure that we always get lowercase emails. If we override our attribute accessors right now, we have to set the instance variable for things to work and we get no benefit from TacoMapper.

require 'active_model'
require 'active_support/all'

module TacoMapper
  extend ActiveSupport::Concern

  module ClassMethods
    def attribute(name)
      attr_accessor(name)
    end
  end
end

class User
  include TacoMapper
  attribute :email

  def email=(value)
    @email = value.to_s.downcase
  end
end

user = User.new
user.email = 'John@Doe.com'
puts user.email # "john@doe.com"

In this simple example, that might be ok. But what if other things were in the mix, like dirty tracking, typecasting, etc.? Those other things would immediately stop working. Would it not be nice if we could just override our accessor and call super to get all the normal functionality of TacoMapper? I am glad you agree.

Second Solution

If you haven’t yet, you might want to read Lookin’ on Up…To the East Side, a post I wrote on how Ruby’s method lookups work. In it, I explain that if you include a module, you can override methods that were in the module and call super. We’ll do the same in TacoMapper so we can make things a bit more robust.

require 'active_model'
require 'active_support/all'

module TacoMapper
  extend ActiveSupport::Concern

  module ClassMethods
    def attribute_accessors_module
      @attribute_accessors_module ||= Module.new.tap { |mod| include(mod) }
    end

    def attribute(name)
      attribute_accessors_module.module_eval <<-CODE
        def #{name}
          @#{name}
        end

        def #{name}=(value)
          @#{name} = value
        end
      CODE
    end
  end
end

class User
  include TacoMapper
  attribute :email

  def email=(value)
    super(value.to_s.downcase)
  end
end

user = User.new
user.email = 'John@Doe.com'
puts user.email # "john@doe.com"

Note that we get the same result as the previous example, except that now we can just call super and still take advantage of all the loveliness that TacoMapper will eventually provide. We changed a couple of key things, so lets cover the differences in detail.

First, we created a method (attribute_accessors_module) that returns a memoized module and includes it in the current class. No matter how many calls you make to this method, it will return the first module we created and it will only be included once, since we memoized it (||=).

Second, since we have a module and it is included in our class, all we have to do is module_eval our accessor methods into it. This is what is happening in the attribute method.

Third, instead of setting the email instance variable in the email= method, we just call super, which will call the method we module_eval’d into our accessors module.

Pretty sweet, eh?

Third Solution

We could stop there, but we said we were going to use ActiveModel a bit, right? ActiveModel has a module for attribute accessors that does the same thing as above and a bit more (although a bit confusing).

require 'set'
require 'active_model'
require 'active_support/all'

module TacoMapper
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix('', '=')
  end

  module ClassMethods
    def attributes
      @attributes ||= Set.new
    end

    def attribute(name)
      attributes << name.to_s
    end
  end

  module InstanceMethods
    def attribute(key)
      instance_variable_get("@#{key}")
    end

    def attribute=(key, value)
      instance_variable_set("@#{key}", value)
    end

    def attributes
      self.class.attributes
    end
  end
end

class User
  include TacoMapper
  attribute :email

  def email=(value)
    super(value.to_s.downcase)
  end
end

user = User.new
user.email = 'John@Doe.com'
puts user.email # "john@doe.com"

Note that again, we get the same result and that we are using super as before. So what changed this time?

First, we included ActiveModel::AttributeMethods. We then took advantage of the attribute_method_suffix method it provides to declare that we would have a reader ('') and a writer ('='). Now all our attribute class method has to do is add the attribute to the set of attributes.

Lastly, we define methods that implement the suffix methods we defined (attribute and attribute=). Note that we also define the attributes instance method so that ActiveModel knows when it is dealing with one of our attributes. Now, it takes only a few more lines of code to add a boolean presence method.

require 'set'
require 'active_model'
require 'active_support/all'

module TacoMapper
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix('', '=', '?')
  end

  module ClassMethods
    def attributes
      @attributes ||= Set.new
    end

    def attribute(name)
      attributes << name.to_s
    end
  end

  module InstanceMethods
    def attribute(key)
      instance_variable_get("@#{key}")
    end

    def attribute=(key, value)
      instance_variable_set("@#{key}", value)
    end

    def attribute?(key)
      instance_variable_get("@#{key}").present?
    end

    def attributes
      self.class.attributes
    end
  end
end

class User
  include TacoMapper
  attribute :email

  def email=(value)
    super(value.to_s.downcase)
  end
end

user = User.new
puts user.email? # false
user.email = 'John@Doe.com'
puts user.email? # true

Using ActiveModel like this makes adding things like dirty tracking take a few minutes instead of a few hours. That said, the main point of this post is how the internals of ActiveModel actually work and how you can do it on your own if you so choose.

For those of you that are MongoMapper users, it has the same functionality built in though in a not as elegant way (which I will be updating soon). Also, your accessors are not used when loading things from the database, as that is handled internally. This means you can make your public accessors do whatever you want and it will not foobar the loading of your documents.

Hope this helps others trying to grok ActiveModel or build your own object mapper. If you enjoyed this post and would like to learn more about building an object mapper, let me know with a comment and I will try to round up a few more posts on the topic.

6 Comments

  1. Thanks for a great review and the examples are awesome. Good stuff! I’d love to see this kind of writeup of how MongoMapper sits on top of the Mongo gem. I’ve look at it a couple times in some of the earlier versions and I didn’t really get it on the first pass. Thanks!

  2. Great read, great info John thanks! I would be interested in more posts on this.

  3. Great read, great info John thanks! I would be interested in more posts on this.

  4. Luke Cowell Luke Cowell

    Aug 31, 2010

    Thanks John! Another excellent post.

  5. great post. i would really like to see more on that topic

  6. It’s serious Lubetube >:[[

Sorry, comments are closed for this article to ease the burden of pruning spam.

About

Authored by John Nunemaker (Noo-neh-maker), a web developer and programmer who has fallen deeply in love with Ruby. More about John.

Syndication

Feed IconRailsTips Articles - An assortment of howto's and thoughts on Ruby and Rails.

Feed IconRails Quick Tips - Ruby and Rails related links.