🪲
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:0x0000000106663f58 @foo="xxx">
-
item.foo
の時点で Foo のインスタンスになっている - foo には "xxx" を直接設定できない
-
Item.create!(foo: "xxx")
はエラー -
item.foo = "xxx"
もエラー - これはメリットでもありデメリットでもある
- デメリットだと感じるなら → 後述
-
クラス名が異なるとき
composed_of :foo, class_name: "Bar"
- DBのカラムは foo だが対応するクラスが Bar のとき
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") rescue $! # => #<Item id: 1, foo: "xxx">
なんてよくできているんだ……
もちろんすでに値オブジェクトになっていれば渡されない。
converter には constructor と同様にブロックも書ける。
as_json 時の違いに注意
composed_of の名前とカラム名が同じとき
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"}}
- composed_of で指定した名前の camelize が値クラスになっているとき as_json の結果は
"name"=>{"name"=>"alice"}
となってしまう - 単に
"name" => "alice"
とするには Name クラスで as_json を定義して name を返せばいい
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
も呼ばれない
検証用ほぼ全部入りコード
require "active_record"
ActiveRecord::VERSION::STRING # => "7.0.4"
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