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.