ROM 0.9.0 Released

We are pleased to announce the release of ROM 0.9.0! This is a big release which focuses on internal clean-up in the core library as a preparation for 1.0.0. For those of you hungry for new features - you won't be disappointed. As part of this release we are introducing new adapters, new gems extracted from rom and the long awaited high-level interface for ROM called rom-repository.

For notes about upgrading to ROM 0.9.0 please refer to Upgrade Guides.

Gem updates summary:

New gems:

  • rom-repository 0.1.0 - a higher-level interface with auto-mapping and additional relation plugins
  • rom-mapper 0.1.1 - standalone mapper objects extracted from rom
  • rom-model 0.1.1 - extracted from rom-rails, includes Attributes and Validator extensions
  • rom-support 0.1.0 - a bunch of small extensions reused across all rom gems

New adapters:

  • rom-couchdb - an adapter for CouchDB
  • rom-http - an abstract HTTP adapter useful for implementing concrete adapters for HTTP APIs
  • rom-rethinkdb - an adapter for RethinkDB

Repository

Probably the most significant addition coming with this release is rom-repository. Using lower-level APIs and configuring mappers manually is tedious in most of the cases that's why Repository was introduced.

Repository interface is very simple and built on top of Relation and Mapper API. It allows you to easily work with relations and have results automatically mapped to struct-like objects. There are a couple of neat plugins that ship with this gem which make relation composition ridiculously simple.

Repositories work with all adapters which means you can combine data from different data sources.

Here's an example repository class:

class UserRepository < ROM::Repository::Base
  relations :users, :tasks

  def with_tasks(id)
    users.by_id(id).combine_children(many: tasks)
  end
end

user_repo.with_tasks.to_a
# [#<ROM::Struct[User] id=1 name="Jane" tasks=[#<ROM::Struct[Task] id=2 user_id=1 title="Jane Task">]>, #<ROM::Struct[User] id=2 name="Joe" tasks=[#<ROM::Struct[Task] id=1 user_id=2 title="Joe Task">]>]

Please refer to Repository Guide for the rationale and more information.

Multi-Environment Support

Initially, ROM supported its setup process through a global environment object. This was a good start that worked well with frameworks like Rails that expect globally accessible objects; however, we're pushing towards removing global state as much as possible.

For that reason in ROM 0.9.0 you can configure the environment as a standalone object, which comes with the benefit of being able to have more than one environment. Why would you want to have many environments? For example for database sharding, or separating components within your application where data comes from different sources and you want to keep them isolated.

Here's an example of a multi-environment setup:

class Persistence::Command::CreateUser < ROM::Commands::Create[:sql]
  relation :users
  register_as :create
end

class Persistence::Query::Users < ROM::Relation[:sql]
  dataset :users
end

command_env = ROM::Environment.new

command_env.setup(:sql, [:postgres, 'postgres://command_host/my_db')
command_env.register_relation(Persistence::Command::CreateUser)

command_container = command_env.finalize.env

command_container.command(:users) # access to defined commands

query_env = ROM::Environment.new

query_env.setup(:sql, [:postgres, 'postgres://query_host/my_db')
query_env.register_relation(Persistence::Query::Users)

query_container = query_env.finalize.env

query_container.relation(:users) # access to defined relations

Global setup process still works, but please refer to upgrade guide if you are using ROM standalone without any framework integration.

Gateway Configuration Support

A new interface for configuring individual adapter gateways has been added. For now the only customization you can make is configuring how relation inferrence should work:

# disable inferring relations from schema
ROM.setup(:sql, [
  :postgres, 'postgres://localhost/db', infer_relations: false
])

# cherry-pick which relations should be inferred
ROM.setup(:sql, [
  :postgres, 'postgres://localhost/db', inferrable_relations: [:users, :tasks]
])

# disallow inferrence for specific relations
ROM.setup(:sql, [
  :postgres, 'postgres://localhost/db', not_inferrable_relations: [:some_table]
])

This feature is useful when you have a big database and you don't want to use ROM to deal with all of your relations.

Extracted Standalone Mappers

You can now install rom-mapper as a standalone gem and use the powerful mapping DSL:

require 'rom-mapper'

class PostMapper < ROM::Mapper
  attribute :title, from: 'post_title'

  wrap :author do
    attribute :name, from: 'post_author'
    attribute :email, from: 'post_author_email'
  end
end

post_mapper = PostMapper.build

post_mapper.call([
{ 'post_title' => 'Hello World', 'post_author' => 'Jane', 'post_author_email' => 'jane@doe.org' }
])
# [{:title=>"Hello World", :author=>{:name=>"Jane", :email=>"jane@doe.org"}}]

Mappers are very powerful, make sure to check out the Mapper Guides.

All Relations Are Lazy

Before 0.9.0, ROM had a separate layer for decorating your relations with a lazy-proxy wrapper. This has caused some confusion and unnecessary complexity, as the relations you defined were not the same thing that the #relation() method returned. It also turned out that implementing rom-repository was more difficult than it should have been.

That's why in ROM 0.9.0 all relations have lazy interface. It means that every relation method you define is auto-curried:

class Users < ROM::Relation[:sql]
  def by_name(name)
    where(name: name)
  end
end

# assuming your container is called `rom`

users = rom.relation(:users)

user_by_name = users.by_name # returns auto-curried relation

user_by_name['Jane'].one! # call later on to apply the required argument

Adapter Query DSL is Public

Starting from ROM 0.9.0, the query interface exposed by individual adapters is public, but it is not recommended to use it directly in your application. Relations should be used to encapsulate data access properly and query DSLs should not leak to the application layer.

# this is considered as a smell
users.where(name: "Jane")

# that's the proper way™
users.by_name("Jane")

Extracted Model Extensions

A couple of useful extensions have been extracted from the rom-rails gem into rom-model. These are standalone components that are based on Virtus and ActiveModel. We have an ambitious plan to rewrite it in the future on top of more powerful tools. Please refer to rom-model README for more information.

Right now you can use Attributes and enhanced Validator objects with nice support for embedded validations:

class AuthorInput
  include ROM::Model::Attributes

  attribute :name, String
  attribute :email, String
end

class PostInput
  include ROM::Model::Attributes

  attribute :title, String
  attribute :author, AuthorInput
end

class PostValidator
  include ROM::Model::Validator

  validates :title, presence: true

  embedded :author do
    validates :name, :email, presence: true
  end
end

input = PostInput[{ title: 'Hello World', author: { name: 'Jane', email: 'jane@doe.org' } }]

validator = PostValidator.new(input)
validator.valid?

HTTP Adapter

The new, abstract rom-http adapter is a fantastic addition to the growing list of ROM adapters. It gives you a solid foundation for building a custom adapter which needs to talk via HTTP protocol. It's pretty flexible, and works like any other rom adapter - which means that you can use either the lower-level relation and mapping APIs or set it up with rom-repository and auto-mapping.

require 'json'
require 'http'

class Users < ROM::Relation[:http]
  dataset :users

  def by_id(id)
    with_path(id.to_s)
  end
end

rom = ROM::Environment.new

rom.setup(:http, {
  uri: 'http://jsonplaceholder.typicode.com',
  request_handler: ->(dataset) {
    HTTP.headers(dataset.headers).public_send(
      dataset.request_method,
      "#{dataset.uri}/#{dataset.name}/#{dataset.path}",
      params: dataset.params
    )
  },
  response_handler: ->(response, dataset) {
    Array([JSON.parse(response.body)]).flatten
  }
})

rom.register_relation(Users)

container = rom.finalize.env

container.relation(:users).by_id(1).to_a
# => GET http://jsonplaceholder.typicode.com/users/1 [ Accept: application/json ]

Support Campaign

As announced a couple of weeks ago, we're running a campaign for sustainable development. We already have people who've decided to donate - thank you so much for your support.

With ROM 0.9.0 we're close to the first stable 1.0.0 release, but there's still a lot to be done. Please consider supporting this great effort.

Please also remember that ROM is a project open for contributions and currently we have 24 repositories under our GitHub organization. There are many adapters looking for maintainers, there are many smaller tasks to do in core libraries, framework integrations and other extensions. Please get in touch if you're interested in contributing <3.

Reporting Issues and Support

All repositories now have their own issue trackers enabled on GitHub. If you find a bug, or have problems using ROM, please report an issue for a specific project. If you're not sure which project it relates to, just report it in the main rom issue tracker, and we'll move it to the right place if needed.

For any random questions and support requests you can talk to us on zulip.

Last but not least - we're looking for help in setting up a Discourse instance on DigitalOcean to make it simpler for people to discuss things as an alternative to gitter.

We've got a discourse instance up and running :)

ROM 1.0.0 - See You at ROSSConf!

In case you missed it, ROM is part of the second edition of ROSSConf in Berlin, where you'll have a chance to contribute to the project. We have a crazy plan to release 1.0.0 during the event or at least close all the remaining issues and get an RC out of the door. :)

We'll be working on the list of issues scheduled for 1.0.0, thus it is important to get as much feedback as possible from you.

Please try out ROM 0.9.0. Let us know your thoughts. Report issues, ideas, comments, anything that can help in specifying what should be done for 1.0.0 will be grately appreciated.

Thanks! <3