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:
- domain model;
- mapper;
- repository.
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.