Using rom-rb with Ruby on Rails


In the previous post I described another approach in building some parts of your Rails application, we had a talk about validations and transactions.

Today’s post looks interesting too, let’s talk about databases and ActiveRecord.

There are a lot of different tutorials and best practices for stay your models thin, move some logic to service layer, move validation from models into form objects and others.

ActiveRecord’s source code have a lot of magic and very-very difficult to understand.

Yep, DSL is pretty cool, looks good, but if you want to change something you should call to Satan or Aaron :)

But it works.

Ruby Object Mapper

Let’s take a look into official http://rom-rb.org/ website.

Ruby Object Mapper (ROM) is a fast ruby persistence library with the goal of providing powerful object mapping capabilities without limiting the full power of the underlying datastore.

More specifically, ROM exists to:

  • Isolate the application from persistence details
  • Provide minimum infrastructure for mapping and persistence
  • Provide shared abstractions for lower-level components
  • Provide simple use of power features offered by the datastore

Ruby Object Mapper is an open-source persistence and mapping toolkit for Ruby built for speed and simplicity.

ROM have a lot of child projects, you can read full list of them here: http://rom-rb.org/status.

Using with Ruby on Rails

First of all bonus - ROM is really fast, in my cases it faster than ActiveRecord.

So, let’s move on and add gems:

Gemfile

gem 'rom-rails'
gem 'rom-sql'

Now we can add entities and organize basic directories structure.

Configuration

Let’s setup ROM first.

config/initializers/rom.rb

ROM::Rails::Railtie.configure do |config|
  config.gateways[:default] = [:sql, ENV.fetch('DATABASE_URL')]
end

And .env.development:

DATABASE_URL=postgres://localhost/qboard_development

Mappers

First of all we need to create mapper, let’s create mappers directory and first QuestionMapper:

class QuestionMapper < ROM::Mapper
  relation :questions

  register_as :question

  model Question

  attribute :title
  attribute :body
  attribute :created_at
  attribute :updated_at
end

We inherit from ROM::Mapper class and set basic relations, attributes and Model, also we need to register our mapper as question.

Models

We need Types here from Dry::Types it’s a type system for Ruby https://github.com/dry-rb/dry-types.

module Types
  include Dry::Types.module
end

Great, now let’s create our ApplicationModel - basic entity for all our project models.

require 'types'

class ApplicationModel < ROM::Struct
  def self.inherited(klass)
    super

    klass.extend ActiveModel::Naming
    klass.include ActiveModel::Conversion

    klass.constructor_type :schema

    klass.attribute :id, Types::Strict::Int.optional.meta(primary_key: true)
  end

  def persisted?
    id.present?
  end
end

Like usual we have persisted? method, then extending and including ActiveModel based things, set constructor type and define id as a primary key.

And our Question model:

class Question < ApplicationModel
  attribute :title, Types::Strict::String
  attribute :body, Types::Strict::String
  attribute :answers, Types::Strict::Array.of(Answer).default([])
  attribute :user, Types::Constructor(User)
  attribute :created_at, Types::Time
  attribute :updated_at, Types::Time
end

Super simple and super clear - just attributes.

Relations

Next big thing - Relation, we use schema here, and set basic relations with :user and :answers, another thing - all method for default generic select statement, we just describe fields to get from database in our requests.

class QuestionRelation < ROM::Relation[:sql]
  gateway :default

  schema(:questions, infer: true) do
    associations do
      belongs_to :user
      has_many :answers
    end
  end

  def all
    select(:id, :title, :body, :user_id, :created_at, :updated_at).order(:id)
  end
end

Repositories

And final entity - Repository, it implements popular Repository pattern.

class QuestionRepository < ROM::Repository::Root
  root :questions

  commands :create, update: :by_pk, delete: :by_pk, mapper: :question

  def all
    questions
  end

  def sorted
    all.combine(:user).combine(:answers).order { created_at.desc }
  end

  def by_id(id)
    questions.by_pk(id).one!
  end

  def query(search_query, user = nil)
    if search_query.present?
      SearchResults::SearchCache.add(user, search_query)
      sorted.where { title.ilike("%#{search_query}%") | body.ilike("%#{search_query}%") }
    else
      sorted
    end
  end

  def by_id_with_answers_and_users(id)
    questions.by_pk(id).combine(:user).combine(answers: :user).one!
  end

  private

  def questions
    super.map_to(Question)
  end
end

In this class we describe all SQL methods to get data from database and set commands behaviour.

Usage

Let’s see again into our QuestionsController parts:

class QuestionsController < ApplicationController

  before_action :authorize, except: [:show, :index]

  before_action :load_searches, only: [:show, :new, :create]

  def index
    @questions = repo.query(params[:q], current_user)
    load_searches
  end

  private

  def repo
    QuestionRepository.new(ROM.env)
  end
end

As you can see, we just create repo method to access our QuestionRepository from controller actions, and simple call to the repo.query method.

Full project at my GitHub: https://github.com/maratgaliev/qboard.