ActiveRecord の composed_of を深掘りする
歴史
composed_of は古くから存在していながら、近年まで忘れ去られていた機能です。github の履歴を辿ってみたらなんと、 rails が SVN から git に移行した 2004年 にすでに存在していました。 rails version で言うと v0.9.1 よりもっと前からあったことになります。
近年 Value Object
と相性が良いのでは?という文脈でイニシエの機能が発掘され、再評価されています。Ruby 3.2 で追加された Data とも親和性が高く、知っていれば使いたくなるような機能です。
歴史的経緯によって直感的にわかりにくい部分がありますので、本記事ではその辺りを深掘りしていきます。
とりあえず実例
Office と User がそれぞれ「郵便番号&住所」というデータを持っているとします。
ActiveRecord::Schema.define do
create_table :offices do |t|
t.string :name
t.string :postal_code
t.string :office_address
end
create_table :users do |t|
t.string :email
t.string :postcode
t.string :address
end
end
よく見るとカラム名に一貫性がありませんね。rename したいところですが、そうもいかないことも実業務ではしばしばあるでしょう。 composed_of
を使えば、その辺りを吸収しつつ住所データを統一的に扱うことが出来ます。
サンプルとして、 "〒#{postcode} #{address}"
のような文字列を返すための class を作りました。
class FullAddress
attr_reader :postcode, :address
def initialize(postcode, address)
@postcode = postcode
@address = address
end
def to_s
"〒#{postcode} #{address}"
end
end
上とほぼ同じ意味ですが、 Ruby3.2 で追加されたData
を使えば以下のようにすっきり書けます。
(< 3.2 の場合は Struct.new
で代用できます)
FullAddress = Data.define(:postcode, :address) do
def to_s
"〒#{postcode} #{address}"
end
end
まず Office
に組み込みます。 FullAddress
の attr 名と offices のカラム名との対応を mapping
に書きます。この mapping
の書き方が少々ややこしいのですが、後述します。
# t.string :postal_code
# t.string :office_address
class Office < ActiveRecord::Base
composed_of :full_address, class_name: 'FullAddress', mapping: {postal_code: :postcode, office_address: :address}
end
class_name
はデフォルトで name.to_s.camelize
になりますのでこの例では実際は省略可能です。
User
にも組み込んでみます。こちらは素直な mapping
になります。
# t.string :postcode
# t.string :address
class User < ActiveRecord::Base
composed_of :full_address, mapping: {postcode: :postcode, address: :address}
end
こんな感じで使えます。
[User.find(1), Office.find(1)].each do |record|
puts record.full_address.to_s
end
複数の model で同じ処理を書けました。
コードを読む
さて、ざっくりと使い道がわかったので、composed_of
が実際に何をしているのか、深掘りしていきます。特に mapping
の書き方はコードを読む方がむしろすっきり理解できると思います。
実装は単独のファイルになっているので、読みやすいです。
composed_of :full_address
宣言によって、おおよそ以下のような reader と writer が定義されます。
# reader
def full_address
@full_address ||= FullAddress.new(postcode, address).freeze
end
# writer
def full_address=(new_full_address)
@full_address = new_full_address.dup.freeze
self.postcode = @full_address.postcode
self.address = @full_address.address
end
reader_method
user.full_adress
を呼び出すと、 mapping
に従ってカラムから値を取得して constructor
に渡し、 FullAddress
インスタンスを生成して返します。
引数の mapping
には composed_of
宣言に渡した mapping
オプションの値がそのまま渡されます。
{a: 1, b: 2}.to_a == [[:a, 1], [:b, 2]]
となることに注意して
251行目 mapping.collect { |key, _| read_attribute(key) }
を読み解くと、 mapping: {postal_code: :postcode, office_address: :address}
のとき、FullAddress.new(postal_code, office_address)
が実行されることがわかります。
mapping の keys の並びで constructor が call されます。
mapping
の values は reader_method では一切参照されません。
なんとなく直感的には、コンストラクタはキーワード引数を用いて FullAddress.new(postcode: postal_code, address: office_address)
となりそうな気がしますが、そのようにはなりません。 composed_of
は ruby にキーワード引数がない時代から存在しているためです。
constructor
オプションに Proc を渡せば、キーワード引数で初期化できます。
composed_of :full_address, 略, constructor: ->(postcode, address) { FullAddress.new(postcode:, address:) }
ですが結局、Proc の引数は mapping
の keys の順となります。
writer_method
次は writer_method を読み解いていきましょう。
その前に、 writer の実例がここまで登場してませんでしたので書いておきます。
composed_of :full_address
宣言によって full_address=
が実装され、以下のようにして office.postal_code
と office.office_address
を更新できます。
office = Office.new
office.full_address = FullAddress.new('1028688', '千代田区九段南1-2-1')
office.postal_code # => '1028688'
ActiveModel の機能により constructor の引数に full_address: FullAddress.new
を渡すことでも同様の結果を得ることができます。
full_address = FullAddress.new('1028688', '千代田区九段南1-2-1')
user = User.new(full_address:)
これが基本的な使い方ですが、FullAddress.new
をいちいち書くのがちょっとしんどいですね。
converter
オプションを使うことで、 full_address: { postcode: '1028688', address: '千代田区九段南1-2-1'}
のように書くことが出来るようになります。
composed_of :full_address, 略, converter: :new
# new(postcode: '1028688', address: '千代田区九段南1-2-1') が呼ばれる
あるいは Proc を渡せます。
composed_of :full_address, 略, converter: ->(obj){ FullAddress.new(obj[:post_code], obj[:address]) }
当該部分のコードはこちら。
その他の注意点
read_attribute
と write_attribute
が使われるので
composed_of
は ActiveRecord
の機能であり、 ActiveModel
では使えません。
また、
# t.integer :postal_code
# t.string :office_address
class Office < ActiveRecord::Base
composed_of :full_address, mapping: {postcode: :postcode, address: :address}
def postcode = postal_code.to_s
def address = office_address
end
のようにしても composed_of
はこれらのメソッドを呼んでくれません。
writer_method
を経由せずにカラムを書き換えると
一度でも full_address
へアクセスすると @aggregation_cache[name]
にオブジェクトがキャッシュされます。その状態で full_address=
を使わずに postcode=
で直接カラムを書き換えた場合は full_address
は更新されません。安全のためには postcode=
や address=
をオーバーライドしてしまうのも手です。
writer_method
内からは write_attribute
が使われるため、以下のように例外を投げるようにしても問題ありません。
# t.string :postal_code
# t.string :office_address
class Office < ActiveRecord::Base
composed_of :full_address, class_name: 'FullAddress', mapping: {postal_code: :postcode, office_address: :address}
def postal_code=(_) = raise 'use full_address='
def office_address=(_) = raise 'use full_address='
end
validation
composed_of
で生成されるインスタンスは freeze されますし、実カラムと composed object の状態は常に一致していることが想定されます。FormObject 等で事前に validation を済ませてから writer へ渡すのが良さそうです。
as_json の挙動
composed_of
は as_json
の挙動には基本的に影響を与えませんが、カラム名と同名で宣言した場合は reader_method
が上書きされるため、 as_json
の結果に影響を与えます。
例えば、最も単純な宣言である
class User < ActiveRecord::Base
composed_of :name
end
は、省略されたオプションを補うと、以下と等価です。
class User < ActiveRecord::Base
composed_of :name, class_name: 'Name', mapping: %i[name name]
end
name
の reader が上書きされているため、
User.new(id:1, name: 'alice').as_json
は
{"id" => 1, "name" => Name.new(name).as_json}
となり、
{"id" => 1, "name" => {"name" => "alice"}}
となります。
この現象は Name#as_json
を定義することで回避できます。
Name = Data.define(:name) do
def as_json = name
end
class User < ActiveRecord::Base
composed_of :name
end
User.new(id:1, name: 'alice').as_json # => {"id"=>1, "name"=>"alice"}
その他オプション等の用法は
が大変よくまとまっておりますので、こちらもぜひどうぞ。
Discussion