First impression about ROM (Ruby Object Mapper)

Posted: Dec 16, 2014

I heard about DataMapper 2 (predecessor of ROM) in 2012 at RubyShift conference in Kyiv, Ukraine. Piotr Solnica explained the idea of this project. The info from the presentation looked awesome. Three layers to work with data were supposed to be there:

I’ve liked this idea, it looked flexible. You decouple your application from a storage. Your model will model your data, it won’t be a reflexion of a table in the database.

DataMapper 2

It was exactly what we needed in our project. Our project works with SOAP API, we use Savon to work with it. Savon is a good library to communicate with API, but it doesn’t allow us to have models similar to ActiveRecord. What about ActiveModel from the ActiveRecord? Yes, we used ActiveModel to create models, but it wasn’t enough for us. We wanted something allowing us to change the persistent layer. For example, today we use SOAP, tomorrow we may use JSON API or DB. Therefore, DataMapper 2 looked good for us as an idea. ActiveModel provides a mechanism to build models, it doesn’t provide any mechanism to work with storages.

In our application SOAP API looks more like a set of methods. There are clear entities, but you have to call different methods to receive different information about an entity. It isn’t simple CRUD where you read info from one place, we have to read it from a few places. Therefore, our attempts to use ActiveModel wasn’t very successful. Models knew about Savon as a result they were tightly coupled to the storage. We wanted to avoid that.

We started using DataMapper 2. We created our internal gem to work with our API. It wasn’t easy, because we started using DataMapper 2 when it hadn’t been released. We’ve been using our internal gem with DataMapper 2 on the production so far :) Yep, DataMapper wasn’t stable, we had to apply a few patches to fix problems. But, it has been working for us. We waited on a stable version, but, it didn’t happen.

Early versions of ROM (before 0.3 version)

DataMapper 2 was renamed to ROM. We tried to migrate from DataMapper 2 to ROM 0.1 and then to ROM 0.2, but it wasn’t successful, because, the way how nested models (data) were supported was changed and it didn’t work for us. It is a crucial feature for us, also, it was so difficult to write own engine (mechanism to work with a storage) for those versions of ROM, we spent a lot of time to do that, even a few patches didn’t help. The last thing which stopped us from the migration was difficulties with converting Axiom relations into requests for SOAP methods. For some reason our engine tried to request data without any restrictions (Axiom relation which we got in our adapter didn’t have any restrictions) and only after reading data, restrictions were applied to filter data. May be we did something wrong or ROM was more ready for relational databases rather than custom storages. Anyway, we didn’t want to use that version of ROM.

ROM 0.3-0.4

I was glad to see that the work was resumed. Yes, there is limited documentation, but a few days ago I migrated from the development version of DataMapper 2 to ROM 0.4. I only spent 8 hrs to migrate 23 models and I was able to create own adapter to work with our SOAP API. What does it mean? This version of ROM got flexibility which our team wanted (also, some experience with DataMapper 2 and early versions of ROM helped me to do that migration :)).

Things which work for us in ROM 0.4

You don’t have any constraints to restrict data which you receive from a repository

In early versions of ROM and DataMapper 2 you could only restrict data by attributes which were defined in your mapper. But, that created a lot of issues for us, because sometimes we had to restrict data by arguments (something like flags to receive a short list of data or a full list of data from the same SOAP method) which were not part of our models. Early versions of ROM didn’t allow us to do that. Now it is possible to use anything what you want:

setup.relation(:users) do
  def with_full_details
    restrict(full_info: true) # this method is a part of your adapter, it can do whatever you want
  end

  def with_short_details
    restrict(full_info: false)
  end
end

rom = setup.finalize

env.read(:users).with_full_details.to_a

Nested models are there again

In early version of ROM it didn’t work for us at all. From my experience the gem was more ready for relational databases, therefore, we could not make it working. Now it works. Btw, we use Virtus to create models, it supports nested values, therefore, it works well with ROM:

class FavoriteSite
  include Virtus.value_object

  attribute :id, Integer
  attribute :title, String
  attribute :url, String
end

class User
  include Virtus.value_object

  attribute :id, Integer
  attribute :name, String
  attribute :favorite_sites, Array[FavoriteSite]

  # any other attributes here
end

setup.mappers do
  define(:users) do
    model User

    group :favorite_sites do
      attribute :id
      attribute :title
      attribute :url
    end
  end
end

Now if the repository returns nested data:

[
  {
    id:       1,
    name: ‘John Wood’,
    favorite_sites: [
      {id: 1, title: ‘Google’, url: ‘http://google.com’}
    ]
  }
]

it will be properly handled:

user = env.read(:users).to_a.first
user.favorite_sites # returns an array with FavoriteSite models

Mapping of attributes

It just works as we expected:

class User
  include Virtus.value_object

  attribute :id, Integer
  attribute :name, String

  # any other attributes here
end

setup.mappers do
  define(:users) do
    model User

    attribute :id, from: :user_id
    attribute :name, from: :full_name
  end
end

This code:

user = env.read(:users).to_a.first
puts user.id
puts user.name

will return proper data if your repository returns something like this:

[
  {
    user_id: 1,
    full_name: ‘John Wood’,
  }
]

Nested mappers

Sometimes you want to map nested data as well, now it is possible. There are some things which we would like to improve, but we will talk about that in another section. This feature is easy to use:

class FavoriteSite
  include Virtus.value_object

  attribute :id, Integer
  attribute :title, String
  attribute :url, String
end

class User
  include Virtus.value_object

  attribute :id, Integer
  attribute :name, String
  attribute :favorite_sites, Array[FavoriteSite]

  # any other attributes here
end

setup.mappers do
  define(:users) do
    model User

    group :favorite_sites do
      attribute :id, from: :favorite_site_id
      attribute :title, from: :name_of_site
      attribute :url, from: :full_url_to_site
    end
  end
end

It will work with the data:

  [
    {
      id:       1,
      name: ‘John Wood’,
      favorite_sites: [
        {favorite_site_id: 1, name_of_site: ‘Google’, full_url_to_site: ‘http://google.com’}
      ]
    }
  ]

Things which may be improved

Create a model, define a relation, create a mapper

Unfortunately, you cannot skip any of this steps. For example, we have a lot of relations which looks like:

setup.relation(:users)

As you can see it is very simple call. The idea of this method is to provide DSL for defining methods to read data:

setup.relation(:users) do
  def by_email(email)
    restrict(email: email)
  end
end

But, in most cases we don’t need any methods here. Therefore, I guess this relation can be created automatically along with creation of a mapper. It is not big issue, but it is some routine which may be avoided.

You have to list all attributes of a model in the schema

Hopefully, we have to list only names of attributes without types. But, again it looks like a duplication, you define attributes in a model and you have to define them in the schema:

class User
  include Virtus.value_object

  attribute :id, Integer
  attribute :name, String
  attribute :favorite_sites, Array[FavoriteSite]

  # any other attributes here
end

setup.schema do
  base_relation :users do
    repository :soap_api

    attribute :id
    attribute :name
    attribute :favorite_sites
  end
end

Yes, I understand that somebody may use something else instead of Virtus to define models. But, some additional method which will read attributes from a model and add them to a schema will help:

setup.define_auto_attibutes_loader do |model|
  model.attribute_set.each do |attr|
    attribute attr.name
  end
end

hence:

setup.schema do
  base_relation :users do
    repository :soap_api

    define_attributes_from User
  end
end

or if a few models are related to one repository:

setup.schema(:ghcp, [User, Follower, Post])

the relation name can be built from a name of the class.

Conclusion

ROM is a good gem, the idea of it is awesome, I would like to say thank you to the team of ROM. It helped us and it will help a lot of other people. I would like this gem to keep following open for extension, but closed for modification principle, please, don’t couple it to relational databases. The good example is methods in relations, it added flexibility to the project, you can restrict your data as you want. It is good example of open/close principle.

Developers don’t hesitate to try this gem now. There are more features which I didn’t touch. Our internal gem is upgraded and we are going to use it in production.

I didn’t try gem for creating/updating data, once I gain this experience I will share it.

Btw, documentation for ROM is here.

This article is only our expirience and my opinion, if you have your own, please, share it in comments.

Hey, do you need to host your project? Just click here and get $10 in credit from DigitalOcean.