🪲

ActiveRecord の composed_of の使い方

2022/12/04に公開

はじめに

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, y1, 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 も呼ばれない
  • このあたり、必要であれば 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