ROM(Ruby Object Mapper)とは
ROM(Ruby Object Mapper)はActiveModelに代表されるようなRubyのORマッパーの1つです。
EntityとRepositoryが分離されていることが特徴で、Hanamiフレームワークで採用されています。
ROMの基本的な使い方を説明していきたいと思います。
準備
gem
必要なgem構成は以下の通り(pry
は任意)です。
# frozen_string_literal: true
source "https://rubygems.org"
ruby '2.4.2'
gem 'rom-repository', '2.0.2'
gem 'rom-sql', '2.3.0'
gem 'sqlite3', '1.3.13'
group :development do
gem 'pry'
end
テーブルの作成
以下のようにするとusers
テーブルが作成できるのでこれを使っていきます。
require 'rom-repository'
require 'pry'
rom = ROM.container(:sql, 'sqlite::memory') do |conf|
conf.default.create_table(:users) do
primary_key :id
column :name , String, null: false
column :email, String, null: false
end
class Users < ROM::Relation[:sql]
schema(infer: true)
end
conf.register_relation(Users)
end
よくわからないものがいきなりたくさん出てきてしましましが、今はこれでテーブルとRelationが作られる、と思ってください。
Relationについては後ほど説明します。
基本的なCreate/Update/Delete
最初にUserRepo
クラスを作成しています。これはDBに対する操作を定義するためのクラスです。DB操作メソッドはROM::Repository
を継承したクラスの中に定義します。
class UserRepo < ROM::Repository[:users]
commands :create, update: :by_pk, delete: :by_pk
end
上記のコードではUserRepo
に対して基本的なCreate/Update/Deleteメソッドを定義しています。
これを使ってみましょう。
Create
user = user_repo.create(name: "Jane", email: "jane@doe.org")
# => #<ROM::Struct[User] id=1 name="Jane" email="jane@doe.org">
user.id # => 1
user.name # => "Jane"
UserRepo
のインスタンスに対して#create
を投げることで、DBへレコードの保存とuserのインスタンスの作成が行われます。このように**「DB操作」と「レコードをマップしたインスタンス」が別のオブジェクトである**というのがActiveRecordなどと比較したときのROMの特徴ですので覚えておきましょう。
Update
Updateは下記のように行います。
updated_user = user_repo.update(user.id, name: "Jane Doe")
# => #<ROM::Struct[User] id=1 name="Jane Doe" email="jane@doe.org">
このとき、ActiveRecordの感覚で以下のようにやってもだめです。
user.name = "Jane Doe"
#=> ROM::Struct::MissingAttribute: undefined method `name=' for #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">
user.save
userはイミュータブルなオブジェクトで、代入するメソッドなどは持っていないので要注意です。
Delate
Deleteは以下のようになります。
user_repo.delete(user.id)
Read
Read用のメソッドquery
とby_id
をUserRepo
に定義しましょう。
class UserRepo < ROM::Repository[:users]
commands :create
def query(conditions)
users.where(conditions).to_a
end
def by_id(id)
users.by_pk(id).one!
end
end
幾つかの新しいキーワードが出てきました。1つずつ確認しましょう。
-
#users
: Relation(後述) -
#where
: Relationを条件で絞り込み。Relationを返す。 -
#by_pk
: Relationを主キーで絞り込み。Relationを返す。 -
#to_a
: RelationをStructの配列に変換 -
#one!
: RelationをStructに変換
上の例では出てきませんでしたが#one
(!
なし)というのもあります。
#one
と#one!
の違いは以下です。
-
#one
: 結果が0件or1件ならStructに変換(0件の場合はnil), 複数件の場合はerror -
#one!
: 結果が1件ならStructに変換, 複数件or0件の場合はerror
定義したメソッドは以下のように使います。
user_repo = UserRepo.new(rom)
user = user_repo.create(name: "Jane", email: "jane@doe.org")
user = user_repo.create(name: "Jane", email: "jane@example.com")
user_repo.query(name: "Jane")
# => [#<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">, #<ROM::Struct::User id=2 name="Jane" email="jane@example.com">]
user_repo.by_id(2)
# => #<ROM::Struct::User id=2 name="Jane" email="jane@example.com">
結果を独自クラスにする
さて、ここまでのReadメソッドでは戻り値がStructでした。これは独自クラスに変更することができます。
class User
attr_reader :id, :name, :email
def initialize(attributes)
@id, @name, @email = attributes.values_at(:id, :name, :email)
end
end
class UserRepo < ROM::Repository[:users]
commands :create
def query(conditions)
users.where(conditions).map_to(User)
end
end
map_to(User)
というのがポイントで、これによって結果をUser
インスタンスにしてくれます。
試して見ます。
user_repo = UserRepo.new(rom)
user_repo.create(name: "Jane", email: "jane@doe.org")
user = user_repo.query(name: "Jane").first
# => #<User @email="jane@doe.org", @id=1, @name="Jane">
user.class # => User
ここで注目したいのはUser
クラスは何も継承もMixinもしていないPureなRubyであるということです。これによってUser
クラスを従来のデザインパターンを使って、好きなだけ表現力豊かなモデルにすることができます。
Relation
さて、さんざん後回しにされていたRelationの説明です。
Relationは低レベルなCRUDのためのAPIを提供するものです。
Relationクラスはこのように定義します。
class Users < ROM::Relation[:sql]
schema do
attribute :id , Types::Int
attribute :name , Types::String
attribute :email, Types::String
primary_key :id
end
end
schema
メソッドでテーブルのカラムと主キーを定義します。
以下のように省略して書くこともできます。
class Users < ROM::Relation[:sql]
schema(infer: true)
end
schema(infer: true)
とすることでクラス名から推測してusers
テーブルの定義を参照してくれます。
Relationにメソッドを書いてみましょう。Relationのメソッドは必ず戻り値もRelationでなければならないというルールがあります。
require 'rom-repository'
require 'pry'
rom = ROM.container(:sql, 'sqlite::memory') do |conf|
conf.default.create_table(:users) do
primary_key :id
column :name , String, null: false
column :email, String, null: false
end
class Users < ROM::Relation[:sql]
schema(infer: true)
def listing
select(:name).order(:name)
end
end
conf.register_relation(Users)
end
class UserRepo < ROM::Repository[:users]
commands :create
def listing
users.listing.to_a
end
end
使ってみます。
user_repo = UserRepo.new(rom)
user = user_repo.create(name: "Jane", email: "jane@doe.org")
user = user_repo.create(name: "Bob" , email: "bob@example.com")
user_repo.listing
# => [#<ROM::Struct::User name="Bob">, #<ROM::Struct::User name="Jane">]
Associations
アソシエーションを使っていきましょう。ここからは以下のテーブルを使います。
rom = ROM.container(:sql, 'sqlite::memory') do |conf|
conf.default.create_table(:users) do
primary_key :id
column :name, String, null: false
column :email, String, null: false
end
conf.default.create_table(:tasks) do
primary_key :id
foreign_key :user_id, :users
column :title, String, null: false
end
users
とtasks
は1対多の関係になっています。この関係を定義しましょう。
rom = ROM.container(:sql, 'sqlite::memory') do |conf|
conf.default.create_table(:users) do
primary_key :id
column :name, String, null: false
column :email, String, null: false
end
conf.default.create_table(:tasks) do
primary_key :id
foreign_key :user_id, :users
column :title, String, null: false
end
conf.relation(:users) do
schema(infer: true) do
associations do
has_many :tasks
end
end
end
conf.relation(:tasks) do
schema(infer: true) do
associations do
belongs_to :user
end
end
end
end
conf.relation
でリレーションに対してアソシエーションを定義しています。
使ってみましょう。
class UserRepo < ROM::Repository[:users]
commands :create
def by_id(id)
users.by_pk(id).one!
end
end
class TaskRepo < ROM::Repository[:tasks]
commands :create
def by_id(id)
tasks.by_pk(id).one!
end
end
user_repo = UserRepo.new(rom)
task_repo = TaskRepo.new(rom)
user = user_repo.create(name: "Jane", email: "jane@doe.org")
task_repo.create(user_id: user.id, title: "foo")
task_repo.create(user_id: user.id, title: "bar")
user = user_repo.aggregate(:tasks).one
# => #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org" tasks=[#<ROM::Struct::Task id=1 user_id=1 title="foo">, #<ROM::Struct::Task id=2 user_id=1 title="bar">]>
user.tasks
# => [#<ROM::Struct::Task id=1 user_id=1 title="foo">, #<ROM::Struct::Task id=2 user_id=1 title="bar">]
#aggregate
を使うことでリレーションを含んだ(Combined
クラスの)インスタンスが生成されます。
以下のように#aggregate
を使わずにuser
からtasks
を使おうとしても失敗します。
user = user_repo.by_id(1)
# => #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">
user.tasks
# => ROM::Struct::MissingAttribute: undefined method `tasks' for #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org"> (attribute not loaded?)
from rom-sample/vendor/bundle/ruby/2.4.0/gems/rom-mapper-1.1.0/lib/rom/struct.rb:112:in `rescue in method_missing'
tasks
から user
を含める場合は以下のようになります。
task_repo.aggregate(:user).first.user
# => #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">
単に、あるtask
に対してuser
を取得するには単純に以下のようにします。
task = task_repo.by_id(1)
# => #<ROM::Struct::Task id=1 user_id=1 title="foo">
user_repo.by_id(task.user_id)
# => #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">
しかし、これはちょっと面倒くさいですね。やっぱりtask.user
の形でアクセスしたいです。
これを実現するためには以下のようなメソッドを用意します。
class TaskRepo < ROM::Repository[:tasks]
commands :create
def by_id(id)
tasks.by_pk(id).one!
end
def by_id_with_user(id)
tasks.by_pk(id).wrap(:user).one
end
end
task = task_repo.by_id_with_user(1)
# => #<ROM::Struct::Task id=1 user_id=1 title="foo" user=#<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">>
task.user
# => #<ROM::Struct::User id=1 name="Jane" email="jane@doe.org">
これでできました!
感想
ROM(Ruby Object Mapper)の基本的な使い方を見てきました。ROMにはまだ説明していない機能がたくさんありますが、まだ著者も分からないことだらけです。できればもうちょっと調べて追記するなり新しく記事を書くなりしたいです。
英語でもいいからもっとドキュメントが増えてほしい!