February 21, 2010

Posted by John

Tagged mongomapper

Older: MongoTips: All Mongo, All The Time

Newer: MongoMapper 0.7: Identity Map

MongoMapper 0.7: Plugins

Whew, been a bit of a whirlwind around here. Lots to do lately, so I have been slow talking about MongoMapper’s 0.7 release from a week ago. I’ll take a crack now. Let’s start with how to extend MongoMapper with plugins and some other fun functionality.

Separation Is Good

Originally, because Document and EmbeddedDocument shared so much, in my head it made sense to just include EmbeddedDocument in Document. What this led to was confusion in the code. Lots of things sharing, some that should not be and some that should. Yes, I wanted to share the functionality between the two modules, but including one in the other is not the best way.

So I sat down and thought about it. I figured the best way would be to split out each bit of functionality into separate modules and then include each of those modules that were shared into both Document and EmbeddedDocument. With this in mind, I went to down. I broke all the refactoring rules and just started ripping stuff out and going to down. What I ended up with is a few tests that, despite all odds, I could not get to pass. My problem was that I took on too much at once.

Thankfully, several hours in, I realized what I had done and stopped. I blew away all my changes and started over. This time, I focused on small changes and made sure to run all the tests after each change. Attacking the code base with this mindset allowed me to slowly and gently remove bits and pieces into modules and begin the process of separating the conjoined twins known as Document and EmbeddedDocument.

Plugins Are Born

As I was doing this, I realized that I was doing the same things over and over. Including a module here, extending one there and occasionally dropping down to class_eval when brute force was needed. That is when it hit me that some sort of system that abstracted this process out would be nice. Thus was born MongoMapper::Plugins. MM::Plugins is the tiniest bit of code, but it led to sweeping changes that make MM far more readable and separated. It is so tiny, in fact, that I will paste it below.

module MongoMapper
  module Plugins
    def plugins
      @plugins ||= []
    end

    def plugin(mod)
      extend mod::ClassMethods     if mod.const_defined?(:ClassMethods)
      include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
      mod.configure(self)          if mod.respond_to?(:configure)
      plugins << mod
    end
  end
end

This small addition made separating out all the shared functionality really easy to complete and really easy to read. For example, when you create a MongoMapper::Document, you include that module. This calls the Ruby included hook, which now looks like this in Document:

def self.included(model)
  model.class_eval do
    include InstanceMethods
    extend  Support::Find
    extend  ClassMethods
    extend  Plugins

    plugin Plugins::Associations
    plugin Plugins::Clone
    plugin Plugins::Descendants
    plugin Plugins::Equality
    plugin Plugins::Inspect
    plugin Plugins::Keys
    plugin Plugins::Dirty # for now dirty needs to be after keys
    plugin Plugins::Logger
    plugin Plugins::Pagination
    plugin Plugins::Protected
    plugin Plugins::Rails
    plugin Plugins::Serialization
    plugin Plugins::Validations
    plugin Plugins::Callbacks # for now callbacks needs to be after validations

    extend Plugins::Validations::DocumentMacros
  end

  super
end

As you can see, all I do is declare a bunch of plugins. It is really easy to see what is going on without even looking at those plugins. This little change was huge mentally for me. It has made the project a joy to maintain once again, as I do not have to think about how to implement each new bit of functionality. The new feature just goes into a new or existing plugin.

An Example Plugin

So what does a plugin look like? Below is the logger plugin:

module MongoMapper
  module Plugins
    module Logger
      module ClassMethods
        def logger
          MongoMapper.logger
        end
      end

      module InstanceMethods
        def logger
          self.class.logger
        end
      end
    end
  end
end

This plugin adds simple methods for accessing MongoMapper’s logging functionality on any Document or EmbeddedDocument. It means if you have a Foo model, you get Foo.logger and Foo.new.logger. Very simple and very readable. Most of the plugins are tiny like this.

Extending All Models

The other cool thing, an idea taken from DataMapper, is append_inclusions and append_extensions. These two methods are added to both Document and EmbeddedDocument and allow you to add any modules to be included or extended on every Document or EmbeddedDocument. Examples work best for me, so check this out:

class Item
  include MongoMapper::Document
end

module Fooer
  def foo
    puts 'Foo'
  end
end

MongoMapper::Document.append_extensions(Fooer)

class Sandle
  include MongoMapper::Document
end

Item.foo # puts 'Foo'
Sandle.foo # puts 'Foo'

Note that it does not matter whether you append_extensions before or after the model is defined. Either way, anything that includes MongoMapper::Document gets the extension. Behind the scenes, this calls extend Fooer on each model. append_inclusions behaves the same except that instead of calling extend with the module, it calls include. See below:

class Item
  include MongoMapper::Document
end

module Fooer
  def foo
    puts 'Foo'
  end
end

MongoMapper::Document.append_inclusions(Fooer)

class Sandle
  include MongoMapper::Document
end

Item.new.foo # puts 'Foo'
Sandle.new.foo # puts 'Foo'

Note on this example that we are calling new and that foo is now an instance method instead of a class method (include vs extend). Also, append inclusions and extensions are added to EmbeddedDocument so you can apply functionality specfically to it if needed.

Adding A Plugin To All Documents

The cool thing about this is if you want to add a plugin to every Document, you can just do something like this, instead of manually calling plugin in each model:

class Item
  include MongoMapper::Document
end

module FooPlugin
  module ClassMethods
    def foo
      puts 'Foo'
    end
  end

  module InstanceMethods
    def foo
      self.class.foo
    end
  end
end

module FooPluginAddition
  def self.included(model)
    model.plugin FooPlugin
  end
end
MongoMapper::Document.append_inclusions(FooPluginAddition)

class Sandle
  include MongoMapper::Document
end

Item.foo        # puts 'Foo'
Sandle.foo      # puts 'Foo'
Item.new.foo    # puts 'Foo'
Sandle.new.foo  # puts 'Foo'

We create the FooPlugin, then we create a module that uses Ruby’s included hook to call plugin on the model. Because append_inclusions is called on each Document, all our models get the new plugin. Fun stuff.

Thanks to plugins and the append extensions/inclusions stuff there is a standardized way of extending MongoMapper models. In fact, people have already hopped on the bandwagon and started adding features as plugins instead of directly into Document or EmbeddedDocument. The first such plugin to be merged in is the protected attributes plugin.

These additions are huge for MongoMapper in general, but I have found them handy for organizing code on Harmony as well.

There is still more work to do in regards to organizing the MM code base, as some plugins are almost catch alls, but this has really cleaned things up in the code, especially for people new to MM.

10 Comments

  1. Yes! This will simplify some API caching libraries I’ve been writing. Can’t wait to use it.

  2. @Wynn – Awesome! Can’t wait to see those libraries. :)

  3. Great post! I’ve been wondering how to split things apart like this, so this really helped me grok the process… Much thanks!

  4. Hi John,

    I really enjoyed the post, but I’m curious why the need for the extra FooPluginAddition module?

    Could you not just do the following?

    module FooPlugin
      def self.included(model)
        model.plugin FooPlugin
      end
    
      module ClassMethods
        def foo
          puts 'Foo'
        end
      end
    
      module InstanceMethods
        def foo
          self.class.foo
        end
      end
    end
    MongoMapper::Document.append_inclusions(FooPlugin)

    Am I missing something obvious?

  5. @Michael – Yes, good point. You could do that as well.

  6. Awesome work! These changes will surely revamp the plugin creation, and will speed up the mongomapper evolution. Thank you!

  7. That’s some nice refactoring, John. Very cool. @Michael, I wondered the same thing. Glad you asked!

  8. Rahsun McAfee Rahsun McAfee

    Feb 22, 2010

    Good post. It’s very much more readable this way. It also actually gave me some ideas. Thanks for sharing.

  9. The plugin module is great, and in general these two latest MongoMapper posts are good, however, wasn’t the idea to post these things on MongoTips from now on? :)

    (Especially the Identity Map post seems quite MongoMapper-specific..)

    Anyways, keep up the good work!

  10. Hello John,
    I appreciated your work on plugins – it was dead simple to hook my plugin on MongoMapper :

    http://github.com/etaque/mongo_squeezer
    (basically a port of clean_empty_attributes)

    Thank you for your work.

Thoughts? Do Tell...


textile enabled, preview above, please be nice
use <pre><code class="ruby"></code></pre> for code blocks

About

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

Projects

Flipper
Release your software more often with fewer problems.
Flip your features.