Introduction » Compared to ActiveRecord
Rails ActiveRecord is the most popular persistence framework in ruby land. Deployed inside the majority of rails applications across the web, it provides APIs for quick and simple data access making it a great solution for constructing CRUD style applications.
Our intention for this guide is to act as a primer for anyone familiar with Rails' ActiveRecord and looking for a quick start guide. Examples in each set will show how ActiveRecord accomplishes each task followed by an example with the equivalent using ROM.
All ROM examples are based on rom-sql
which is an adapter needed to use SQL
databases with ROM. Information on installing and configuring rom-sql for your
database can be found in the
SQL guide.
Examples below assume a configured environment for each
framework. For ROM examples this means an initialized ROM::Container
with
each component registered.
For information on how to configure a ROM environment see either Setup DSL or Rails Setup guides.
Both frameworks have many similar APIs but philosophically they are completely different. In this guide, we attempt to highlight these differences and provide context for why we chose a different path. That is not to say ROM is better than ActiveRecord or vise-versa, it's that they're different and each has its own strengths and weaknesses.
Models vs Relations
The first difference is ROM doesn't really have a concept of models. ROM objects are instantiated by the mappers and have no knowledge about persistence. You can map to whatever structure you want and in common use-cases you can use relations to automatically map query results to simple struct-like objects.
The closest implementation to models would be ROM::Struct
, which is
essentially a data object with attribute readers, coercible to a hash.
More on ROM Structs later.
Active Record
class User < ApplicationRecord
end
ROM
class Users < ROM::Relation[:sql]
schema(infer: true)
end
As you can see, ActiveRecord and ROM have similar boilerplate and as this guide progresses both will use similar APIs to accomplish the same tasks. The difference between models and relations lies within their scope and intended purposes. ActiveRecord models represent an all encompassing thing that contains state, behavior, identity, persistence logic and validations whereas ROM relations describe how data is connected to other relations and provides stateless APIs for applying views of that data on demand.
Models vs ROM Structs
The most direct analog to ActiveRecord models in ROM is a ROM::Struct
. ROM
structs provide a quick method for adding behavior to mapped data returned from
a relation. A custom type or plain hash can also be used instead, but ROM
structs offer a fast alternative without having to write a lot of boilerplate.
Active Record
class User < ApplicationRecord
def first_name
name.split(' ').first
end
def last_name
name.split(' ').last
end
end
user = User.first
#> #<User id: 1, name: "Jane Doe">
user.first_name
#> "Jane"
user.last_name
#> "Doe"
ROM
class Users < ROM::Relation[:sql]
struct_namespace Entities
schema(infer: true)
end
module Entities
class User < ROM::Struct
def first_name
name.split(' ').first
end
def last_name
name.split(' ').last
end
end
end
user = users_relation.first
#> #<Entitites::User id=1 name="Jane Doe">
user.first_name
#> "Jane"
user.last_name
#> "Doe"
For a brief overview and links to more in-depth information about relations see the Relations section in our Core Concepts guide.
Queries
Basic Queries
Once you have a relation, it becomes almost trivial to start querying for information in a similar fashion as ActiveRecord. A basic example below:
Active Record
User.where(name: "Jane").first
#> #<User id: 1, name: "Jane">
ROM
users_relation.where(name: "Jane").first
#<ROM::Struct::User id=1 name="Jane">
Query Subset of Data
Active Record
User.select("name").where(name: name).first
#> #<User id: nil, name: "Jane">
ROM
users_relation.select(:name).where(name: name).one
#> #<ROM::Struct::User name="Jane">
Query with Complex Conditions
Active Record
User.where("admin IS ? OR moderator IS ?", true, true)
ROM
users_relation.where { admin.is(true) | (moderator.is(true)) }
For several SQL keywords, such as select
& where
, ROM provides a DSL for
blocks. The benefit is the ability to use any SQL functions supported by your
database.
Associations
Similar to ActiveRecord, ROM uses associations as a means of describing the interconnections between data.
Join Query
Active Record
Article.joins(:users)
ROM
articles_relation.join(:users)
Obviously the join interface for both frameworks can support different configurations to handle different types of joins, however this example illustrates that other than a minor name change, in the majority of use-cases they will act the same.
Persistence
Creating Simple Objects
Active Record
User.create(name: "Jane")
#> #<User id: 1, name: "Jane">
ROM
users_relation
.changeset(:create, name: "Jane")
.commit
#> #<ROM::Struct::User id=1 name="Jane">
Changesets are an abstraction created over commands which are what actually manipulate stored records. They are preferred over commands due to additional functionality they provide.
Updating Simple Objects
Active Record
user = User.find_by(name: "Jane")
user.update(name: "Jane Doe")
#> #<User id=1 name="Jane Doe">
ROM
users_relation
.where(name: "Jane")
.changeset(:update, name: "Jane Doe")
.commit
#> #<ROM::Struct::User id=1 name="Jane Doe">
It should be noted that updating a record in ActiveRecord generally requires that record to first be loaded then updated then committed. We view this as a bad practice as it leads to more round trips from the database and entities that are initialized in an invalid state. If a developer is sufficiently validating data at the boundaries of the application then updating or creating a record without loading it should be no problem and in fact preferable.
Validation
ActiveRecord mixes domain-specific data validation with persistence layer. An active record object validates itself using its own validation rules. We feel this ultimately ends up complicating persistence logic especially when tuning queries in larger projects as the single source of validation needs to work in every context the model is used.
ROM on the other hand does not have a validation concept built-in. Validations in ROM projects need to be handled externally by separate libraries and validated data can be passed down to the command layer to be persisted. We expect users to validate data at the system boundaries using rules that make sense in the current context.
Where ROM Shines
Database Support
As long as there is an adapter, ROM can theoretically support any datastore.
class Users < ROM::Relation[:mongo]
schema do
attribute :_id, Types::ObjectID
attribute :name, Types::String
end
end
Cross Database Associations
Couple multi-database support with cross database associations and suddenly a world of opportunity opens up.
class Users < ROM::Relation[:sql]
schema(infer: true) do
associations do
has_many :tasks, override: true, view: :for_users
end
end
end
class Tasks < ROM::Relation[:yaml]
gateway :external
schema(infer: true)
def for_users(_assoc, users)
tasks.restrict(UserId: users.pluck(:id))
end
end
Mapping Custom Models
class CustomUser < MySuperModelLibary
end
users_relation.map_to(CustomUser).first
#> #<CustomUser id="1", username="Joe">
ROM does not care what your final output object is as long as it accepts a hash of all the attributes and their values. Coupled with other mappers, the output from a query can be incredibly flexible.
SQL Functions
Active Record
User
.select("*, concat(first_name, ' ', last_name) as 'full_name')")
.first
#> #<User id=1 full_name="Jane Doe">
ROM
users_relation.select_append {
str::first_name.concat(' ', last_name).as(:full_name)
}.first
#> #<ROM::Struct::User id=1, full_name="Jane Doe">
Legacy Schemas
Active Record
class User < ApplicationRecord
self.table_name = 'SomeHorriblyNamedUserTable'
self.primary_key = 'UserIdentifier'
alias_attribute :id, :UserIdentifier
alias_attribute :name, :UserName
end
User.find_by(name: 'Jane')
#> #<User UserIdentifier: "2", UserName: "Jane">
User.where('name IS ?', 'Jane').first
# 🔥🔥 KA-BOOM! 🔥🔥
# ActiveRecord::StatementInvalid: no such column
ROM
class Users < ROM::Relation[:sql]
schema(:SomeHorriblyNamedUserTable, as: :users) do
attribute :UserIdentifier, Serial.meta(alias: :id)
attribute :UserName, String.meta(alias: :name)
end
end
users_relation.where(name: 'Jane').first
#> #<ROM::Struct::User id=1 name="Jane">
ROM makes working with legacy schemas a breeze. All that's needed it to define attributes on the relations schema along with return types and aliases. Afterwards just reuse the aliased names throughout your ROM queries - quick and easy.
Working with ActiveRecord in this regard is a bit more difficult. While you
can alias attributes, there is no real supported method for changing attribute
names. Worse yet, ActiveRecord breaks the rule of Least Surprise because while
some parts of the ActiveRecord API takes alias_attribute
into account,
arel
does not, causing performance tuning SQL queries to fall back on the
ugly database attribute names you were trying to avoid.
Custom Mappers
class EncryptionMapper < ROM::Mapper
register_as :encryption
def call(relation)
relation.map {|tuple|
# do whatever you want
}
end
end
users.map_with(:encryption)
Transform Data Before Persisting
Not only can data be transformed when reading records from the database, they can also be transformed just before storage as well. Changesets offer a built in method for executing a set of transformations that can be used to make minor adjustments such as the example below, where an attribute needs to be renamed. They can also handle more powerful transformations such as flattening nested objects. For more information on available transformations see Transproc
class NewUser < ROM::Changeset::Create
map do
rename_keys user_name: :name
end
end
users_relation.changeset(NewUser, user_name: "Jane").commit
NEXT
To further understand ROM it is recommended to review the Core Concepts page followed by the guides under Core.