August 29, 2010
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
Aug 29, 2010
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!
Aug 29, 2010
Great read, great info John thanks! I would be interested in more posts on this.
Aug 29, 2010
Great read, great info John thanks! I would be interested in more posts on this.
Aug 31, 2010
Thanks John! Another excellent post.
Sep 22, 2010
great post. i would really like to see more on that topic
Jan 20, 2011
It’s serious Lubetube >:[[
Sorry, comments are closed for this article to ease the burden of pruning spam.