Edited at
HanamiDay 17

ROM(Ruby Object Mapper)の基本的な使い方

More than 1 year has passed since last update.


ROM(Ruby Object Mapper)とは

ROM(Ruby Object Mapper)はActiveModelに代表されるようなRubyのORマッパーの1つです。

EntityとRepositoryが分離されていることが特徴で、Hanamiフレームワークで採用されています。

ROMの基本的な使い方を説明していきたいと思います。


準備


gem

必要なgem構成は以下の通り(pryは任意)です。


Gemfile

# 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テーブルが作成できるのでこれを使っていきます。


app.rb

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の感覚で以下のようにやってもだめです。


NG

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用のメソッドqueryby_idUserRepoに定義しましょう。

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でなければならないというルールがあります。


app.rb

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

userstasksは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を使おうとしても失敗します。


NG

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にはまだ説明していない機能がたくさんありますが、まだ著者も分からないことだらけです。できればもうちょっと調べて追記するなり新しく記事を書くなりしたいです。

英語でもいいからもっとドキュメントが増えてほしい!