ActiveRecord の composed_of の使い方
はじめに
ActiveRecord の composed_of は大昔からある機能だけどクラスでラップするメリットをよくわかっていなかった。first_name と last_name をまとめて何が嬉しいのだろうかと。敬遠していた理由として mapping オプションの例が配列の配列になっていたのが気持ち悪くて使いづらそうであまり良い印象を持たなかったのもある。
しかし、ドメイン駆動設計を実戦するうえでこれはとてつもなく有用だと気づいた。もっと早くから活用していればあれもこれももっとうまくいったのに……と後悔しつつ今後に向けて復習する。
基本形
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table :items do |t|
t.string :foo
end
end
class Foo
attr_reader :foo
def initialize(foo)
@foo = foo
end
end
class Item < ActiveRecord::Base
composed_of :foo
end
item = Item.create!(foo: Foo.new("xxx")) # => #<Item id: 1, foo: "xxx">
item.foo # => #<Foo:0x0000000104212d20 @foo="xxx">
-
item.foo
の時点で Foo のインスタンスになっている - foo には "xxx" を直接設定できない
-
Item.create!(foo: "xxx")
はエラー -
item.foo = "xxx"
もエラー
-
- これはメリットでもありデメリットでもある
- デメリットだと感じる場合 → 後述
クラス名が異なるとき
composed_of :foo, class_name: "Bar"
DBのカラムは foo だが対応するクラスが Bar のときは class_name オプションをつける。
そもそもどちらかをリネームして統一しろやって話だが、リファクタリングの途中だったり、多人数で開発していると思い通りに行かない場合もある。
DBのカラムが異なるとき
composed_of :foo, mapping: { :bar => :foo }
- DBのカラムが bar とき
- mapping の引数は一見どっちがDBのカラムなのかわからない
- composed_of を書いている側が主役と考えれば左側のキーがDB側となって自然かも
- ドキュメントでは mapping のペアを配列で書いているがハッシュで書くべき
- その方がペアだと伝わりやすい
値オブジェクトのメソッドが異なるとき
class Foo
attr_reader :bar
# ...
end
composed_of :foo, mapping: { :foo => :bar }
- 右側が参照時のメソッド名なので bar にする
DBのカラムも値オブジェクトのメソッドもバラバラなとき
composed_of :foo, mapping: { :bar => :baz }
- DB側が bar で値オブジェクト側が baz
値クラスに nil が渡されてしまうとき
composed_of :foo, allow_nil: true
- DBの設計がおかしい
- DB側で
NOT NULL
制約にできないか検討する - それがだめなら正規化する
- それがだめなら最後の最後に妥協して
allow_nil: true
をつける - 基本的に
allow_nil: true
なんかつけちゃいけない - 注意点
-
allow_nil: true
はすべてのカラムが nil ならスキップするという挙動になっている - だから複数あるカラムのなかで1つだけ値があったときは生成しようとする
- たとえば
x, y
が1, nil
だったとき「生焼け値オブジェクト」ができあがる - その場合は完全コンストラクタ化するとか constructor オプションで弾く
-
- このように NULL を許可するとやたら面倒になる
値オブジェクトは new ではなく create で作ってほしいとき
composed_of :foo, constructor: :create
- これで
Foo.new
ではなくFoo.create
が呼ばれる - constructor は「DB側」から「値オブジェクト」への変換の際に使う
値オブジェクトの生成方法が変なとき
composed_of :foo, constructor: -> value { Foo.new(value: value) }
- まず Foo のコンストラクタを
Foo.new(value)
となるように直す - それが無理なら
Foo.create(value)
などのファクトリメソッドを別に作る - それも無理なら妥協してブロックを使って自力で書く
- この機能を使って自力で書いたときかつ値クラスを
class_name
オプションに指定しているときに、class_name
オプションは不要になりそうだが書き込み系で使ってるのでいる
プリミティブ値の入力を許容するには?
composed_of :foo
となっているとき foo には当然 Foo のインスタンスを与えないといけない。
Item.create!(foo: Foo.new("xxx")) # => #<Item id: 1, foo: "xxx">
なので次のようにプリミティブ値を書くとエラーになる。
Item.create!(foo: "xxx") rescue $! # => #<NoMethodError: undefined method `foo' for "xxx":String>
たしかにプリミティブ値を書いたんじゃ何のために値オブジェクト化しようとしているのかわからないので、なんでというより、エラーにしてくれたことに感謝すべきである。しかし、あまりに影響範囲が広すぎて composed_of の使用を諦めるぐらいなら次のようにしておく。
composed_of :foo, converter: :new
すると Foo.new
に渡してくれる。
Item.create!(foo: "xxx") # => #<Item id: 1, foo: "xxx">
なんてよくできているんだ……。もちろん、すでに値オブジェクトになっていれば渡されない。converter には constructor と同様にブロックも書ける。
したがって、このオプションを活用すれば既存のプロジェクトを緩やかにリファクタリングしていくことが可能になる。
as_json 時の違いに注意
composed_of の名前とカラム名が同じとき
composed_of で指定した名前の camelize が値クラスになっているとき、
class Name
attr_reader :name
def initialize(value)
@name = value
end
end
class User < ActiveRecord::Base
composed_of :name
end
user = User.create!(name: Name.new("alice"))
user.as_json # => {"id"=>1, "name"=>{"name"=>"alice"}}
as_json は "name" => "alice"
を返すのを期待するだろうが、実際は "name"=>{"name"=>"alice"}
となってしまう。
これはこれで間違ってはいないし、値オブジェクトが今後膨らむ可能性があるなら、そっとしておけばいいのだが、値オブジェクト化する前の構造に合わせる必要があったり、どうしても気になる場合は Name クラスで as_json を定義して name だけを返せばよい。
class Name
def as_json(*)
name
end
end
user.as_json # => {"id"=>1, "name"=>"alice"}
つまり as_json をいじれば表面上はなんとでもなる。
composed_of の名前とカラム名が異なるとき
class Vec
attr_reader :x, :y
def initialize(x, y)
@x, @y = x, y
end
end
class User < ActiveRecord::Base
composed_of :vec, mapping: { vx: :x, vy: :y }
end
user = User.create!(vec: Vec.new(1, 2))
user.as_json # => {"id"=>1, "vx"=>1, "vy"=>2}
- ひと目
{"vec" => {"x" => 1, "y" => 2}}
が含まれていてよさそうだが含まれない- vec が参照されないので
Vec#as_json
も呼ばれない
- vec が参照されないので
- このあたり、必要であれば as_json をオーバーライドしたり
methods: :vec
オプションをつけるなどして工夫する
user.as_json(methods: :vec) # => {"id"=>1, "vx"=>1, "vy"=>2, "vec"=>{"x"=>1, "y"=>2}}
検証用ほぼ全部入りコード
require "active_record"
ActiveRecord::VERSION::STRING # => "7.1.2"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table :items do |t|
t.integer :vector_x
t.integer :vector_y
end
end
class Vec2d
attr_reader :x, :y
class << self
private_class_method :new
def create(...)
new(...)
end
end
def initialize(x, y)
@x, @y = x, y
end
def inspect
[x, y].inspect
end
end
class Item < ActiveRecord::Base
composed_of :vector, {
:class_name => "Vec2d", # Vector クラスではないため
:mapping => { vector_x: :x, vector_y: :y },
:allow_nil => true,
:constructor => :create, # または -> x, y { Vec2d.create(x, y) }
:converter => -> xy { Vec2d.create(*xy) }, # 引数が1つなら converter: :new でよい
}
end
# 基本形
Item.create!(vector: Vec2d.new(1, 2)).vector # => [1, 2]
# DB側のカラムを意識して設定できるけど値オブジェクトにした意味がなくなるので推奨しない
Item.new(vector_x: 1, vector_y: 2).vector # => [1, 2]
# allow_nil: true の効果で nil になっている
Item.new.vector # => nil
# ただし複数カラムの場合1つでもあれば値クラスに渡って生焼けオブジェクトができあがる
Item.new(vector_x: 1).vector # => [1, nil]
# converter: -> xy { Vec2d.create(*xy) } の効果でプリミティブ値が渡せている
Item.create!(vector: [1, 2]).vector # => [1, 2]
# "vector" => { "x" => 1, "y" => 2 } が含まれてもよさそうだけど含まれない
Item.create!(vector: [1, 2]).as_json # => {"id"=>3, "vector_x"=>1, "vector_y"=>2}
Discussion