Porting Rails Plugins to DataMapper

As a follow-up to my previous post, here's some gotchas to be aware of if you're looking to support both ActiveRecord and DataMapper in a Merb (and/or Rails) plugin.

The Strategy

The best way I've found to handle multiple ORM support in your plugin is not to start monkeypatching around to make one ORM handle like another. I've done it, and can tell you that wrapping one ORM's backend into another is ugly.

The better way is to localize the points where your plugin interacts with the data model, with an eye to swapping them out. For the above example, I would be better off taking the method that used the reflection method and putting it inside an ActiveRecord-specific module. Then, create a DataMapper-specific module that defines the same method, but instead relies on the DM backend to get at the association information. Finally, when the plugin was loaded I could just include one of the modules based on which ORM was loaded into the runtime.

Basic Translation

Now that we have a plan, we can start translating our extracted functions from AR bits to DM bits. There's a bunch of fairly straightforward transformations we can make.

It's unfortunate that there isn't more unity between the two, as from a library-developer's perspective it would make this sort of thing much easier, but the DM team is pretty vocal about wanting the best API they can get, and not worrying about being hobbled by how AR does things. I don't particularly disagree.

ActiveRecordDataMapper
.find(:all, ...) .all(...)
.find(:first, ...) .first(...)
.find(id) .get(...)
.find_all_by_id(id).all(:id => ids)
.table_name .storage_name
.primary_key .key.first.name
.connection repository.adapter

Raw SQL

If you're running raw SQL queries, firstly, I'm sorry. Secondly, you want to run .query instead of .execute. Thirdly, if you care about getting the results of the query back, AR returns an array of arrays, DM returns an array of hashlike objects, so you want to map them for their values array. The hashlike object in question is order-preserving, so you'll get things out in the right order. If you're concerned, grab one of the result objects and verify that the keys array is in the correct order.

Hooks

ActiveRecord defines a few hook points, along the lines of before_create and after_save. DataMapper uses (a modified version of) the Extlib gem, allowing it to hook pretty much any method. The syntax is like before(:create) and after(:save). AR's hooks pass in the object to work with, DM's have the object available as self.

In before hooks, the AR hook chain stops if your method returns false, in DM you must throw :halt.

Things You Shouldn't Be Doing Anyways

If you're manually setting @attribute values in your AR code, you'll need to use instance_variable_set for DataMapper. I recommend writing manual accessor methods to wrap it for abstraction.

If you're wanting some arbitrary data structures back, I recommend using OpenStruct (require 'ostruct') to pass structured data back and forth. This was especially handy when I wanted some results from DM to look like AR, because I was just doing an adapter (bad me!) and the client code wanted to interact with the AR object. Just be aware that OpenStruct doesn't quite clear out all its methods, so you might want to define some custom readers anyway. I had problems with type in particular, which is a deprecated alias of class - redefining the method to return @table[:type] fixed that up nicely.

Using Datamapper 0.9

So, DataMapper 0.3 was behaving weirdly for me, and I thought I'd try upgrading to 0.9 to see how things are there. Overall I'm quite liking it, but there's a few catches:

  • It's incompatible with Vlad for deployment, haven't looked into it but something in DM is making the 'repository' value unsettable, which Vlad uses to determine the checkout path.

  • Legacy connections beware, there doesn't seem to be a current alternative for settablename at the moment.

  • :memory: is no longer a good name for your test database when using sqlite. Use a fully-qualified connection string like sqlite://:memory: instead.

  • DataMapper::Persistence has been renamed DataMapper::Resource. Include it in models.

  • Validations are an add-on now, include DataMapper::Validate in your model (or even re-open DM:Resource and include it there).

  • Properties now take a class instead of a symbol (you can guess at all the main ones), and require the id to be specified like so: property :id, Fixnum, :serial => true

  • Associations are renamed, hasone is now onetoone, hasmany is oneto_many or manyto_many, etc. Haven't delved in deep yet though, so I'm not sure how to define who gets the foreign key.

  • New migration code is just now getting in to dm-core, and auto_migrate! is a thing of the past. Sucks for those of us using sqlite in-memory test databases that need a fresh migration every time.

My installation Rakefile follows, just stuff it in an empty directory and it'll do everything from there. Much thanks to Atmos for a good starting point and some setup help.

desc "Fetch and Install DM and Merb"
task :install_all do 
  config = CONFIG['dm']
  fetch config[:user], config[:repos]
  install config[:install]
  config = CONFIG['merb']
  fetch config[:user], config[:repos]
  install config[:install]
end

desc "Uninstall DM and Merb"
task :uninstall_all do
  uninstall CONFIG['dm'][:gems]  
  uninstall CONFIG['merb'][:gems]  
end

desc "Download latest sources for :project from git"
task :fetch, :project do |task, args|
  config = CONFIG[args[:project]]
  fetch config[:user], config[:repos]
end

desc "Install :project from git"
task :install, :project do |task, args|
  config = CONFIG[args[:project]]
  install config[:install]
end

desc "Uninstall :project"
task :uninstall, :project do |task, args|
  config = CONFIG[args[:project]]
  uninstall config[:gems]
end

def fetch(user, repos)
  base = File.expand_path(".")
  Dir.chdir base do
    repos.each do |repo|
      repo_dir = "#{base}/#{repo}"
      unless File.directory?(repo_dir)
        %x{git clone git://github.com/#{user}/#{repo}.git }
      end
      Dir.chdir(repo_dir) { %x{git pull} }
    end
  end
end

def install(modules)
  base = File.expand_path(".")
  modules.each do |lib|
    Dir.chdir("#{base}/#{lib}") do
      cmd = "sudo rake install 2>/dev/null |" +
            " grep -v '^(in' |" +
            " grep -v '^[0-9] gem' |" +
            " grep -v '^[IUc. ]'"
      puts %x{#{cmd}}
    end
  end
end

def uninstall(gems)
  gems.each do |name|
    puts %x{yes | sudo gem uninstall #{name} -aI}
  end
end

CONFIG = {
  'dm' => {
    :user => 'sam',
    :repos => %w(
      do
      dm-core
      dm-more),
    :install => %w(
      do/data_objects
      do/do_sqlite3
      do/do_mysql
      do/do_postgres
      dm-core
      dm-more/merb_datamapper
      dm-more/dm-migrations
      dm-more/dm-serializer
      dm-more/dm-validations
    ),
    :gems => %w(
      data_objects
      do_sqlite3
      do_mysql
      do_postgres
      dm-core
      merb_datamapper
      dm-migrations
      dm-serializer
      dm-validations
    )
  },
  'merb' => {
    :user => 'wycats',
    :repos => %w(
      merb-core
      merb-more
      merb-plugins
      merb-plugins/merb_param_protection),
    :install => %w(
      merb-core
      merb-more
      merb-plugins
    ),
    :gems => %w(
      merb
      merb-action-args
      merb-assets
      merb-builder
      merb-cache
      merb-core
      merb-gen
      merb-haml
      merb-mailer
      merb-more
      merb-parts
      merb_activerecord
      merb_datamapper
      merb_helpers
      merb_param_protection
      merb_rspec
      merb_sequel
      merb_stories
      merb_test_unit
    )
  }
}