🧰

ActiveRecord の composed_of を深掘りする

2024/02/22に公開

歴史

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 の書き方はコードを読む方がむしろすっきり理解できると思います。
実装は単独のファイルになっているので、読みやすいです。
https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/aggregations.rb#L225-L245

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 インスタンスを生成して返します。

https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/aggregations.rb#L248-L259

引数の 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_codeoffice.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]) }

当該部分のコードはこちら。
https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/aggregations.rb#L261-L284

その他の注意点

read_attributewrite_attribute が使われるので

composed_ofActiveRecord の機能であり、 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_ofas_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"}

その他オプション等の用法は
https://zenn.dev/megeton/articles/742b0669ccd177
が大変よくまとまっておりますので、こちらもぜひどうぞ。

ハートレイルズ

Discussion