🪲

ActiveRecord の composed_of の使い方

2022/12/04に公開約5,600字

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, 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") 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

ログインするとコメントできます