バリューオブジェクトを利用しやすくするためのActiveRecord Attribute API, ActiveModel Attribute APIでのカスタムクラスの活用

サブジェクトが長い。

tl;dr

  • Attribute APIにはカスタムクラスを登録できるのでバリューオブジェクトを使うのに便利
  • 宣言に合わせて自動で変換してくれるので、デシリアライズのときにも便利
  • バリューオブジェクトを積極的に使っていきたい

ActiveRecord Attribute APIの利用

ActiveRecordにはAttribute APIというのがあって defaultlimit が指定できて便利です。 ActiveRecord::Attributes::ClassMethods

Boolean型の属性に"false""off" などのそれっぽい文字列を入れると false というBoolean型への変換をしてくれているのもこのレイヤーです。 rails/boolean.rb at d269b5a4cf3fbd8072927e6fda4a2e38dc640d2c · rails/rails · GitHub

Attribute APIにはカスタムクラスを定義することもできるので、バリューオブジェクトの利用も容易になります。

例えば以下のようなテーブルとそれに対応したモデルがあって、

# db/migrate/20211117084043_create_cakes.rb
class CreateCakes < ActiveRecord::Migration[6.1]
  def change
    create_table :cakes do |t|
      t.integer :price

      t.timestamps
    end
  end
end

# app/models/cake.rb
class Cake < ApplicationRecord
  attribute :price, :list_price
end

attributeとして list_price という型が指定されていると、バリューオブジェクトとして以下が定義されていて、かつ、シリアライザーが同時にこのように定義されていて、

# app/models/list_price.rb
class ListPrice
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :real_price, :integer

  def with_tax
    real_price * 1.1
  end

# スコープを狭める方がいいのでクラス内に定義
  class Type < ActiveModel::Type::Integer
   def cast_value(value)
      if value.is_a?(Integer)
        ListPrice.new(real_price: value)
      else
        super
      end
    end

    def serialize(value)
      value.real_price
    end
  end  
end

initializerなどでActiveRecordの型(ActiveModelの型とは区別されているので注意)として登録されていると、

# config/initializers/types.rb
ActiveRecord::Type.register(:list_price, ListPrice::Type)

以下のように、newするときに意識することなく、バリューオブジェクトを使うことができ、かつ、DBにアクセスするときは自動でキャストしてくれます。

irb(main):008:0> cake = Cake.new(price: 5400)
=> #<Cake id: nil, price: #<ListPrice:0x00005601c7c72a30 @attributes=#<ActiveModel::AttributeSet:0x00005601c7c72990 @attributes={"...
=> #<ListPrice:0x00005601c7c72a30 @attributes=#<ActiveModel::AttributeSet:0x00005601c7c72990 ...
irb(main):010:0> cake.price.with_tax
=> 5940.000000000001
irb(main):012:0> cake.save
  TRANSACTION (0.1ms)  begin transaction
  Cake Create (24.9ms)  INSERT INTO "cakes" ("price", "created_at", "updated_at") VALUES (?, ?, ?)  [["price", 5400], ["created_at", "2021-11-17 09:40:26.428082"], ["updated_at", "2021-11-17 09:40:26.428082"]]
  TRANSACTION (32.9ms)  commit transaction
=> true
irb(main):013:0> c2 = Cake.first
  Cake Load (6.4ms)  SELECT "cakes".* FROM "cakes" ORDER BY "cakes"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Cake id: 1, price: #<ListPrice:0x00005601c65a3f70 @attributes=#<ActiveModel::AttributeSet:0x00005601c65a3e80 @attributes={"re...
irb(main):014:0> c2.price.with_tax
=> 5940.000000000001
irb(main):015:0> c2.price.real_price
=> 5400

ActiveModel Attribute APIでの利用

Railsガイド にはないですが、ActiveModelで属性を定義するときにActiveModel::Attributes::ClassMethodsを使うと同じように定義ができて便利です。そして、ここでも同じようにカスタムクラスを使うことができます。

class Bread
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :flavor, :string
  attribute :price, :list_price
end

ここで一つ注意なのは、ActiveRecordのAttribute APIの型の登録とActiveModelの型の登録は区別されているため、どちらにも使うには2回登録する必要があります。

# config/initializers/types.rb
ActiveRecord::Type.register(:list_price, ListPrice::Type)
ActiveModel::Type.register(:list_price, ListPrice::Type)

これらを登録すると以下のように、ActiveModelのクラスでもシームレスにバリューオブジェクトを利用できます。

irb(main):018:0> brea = Bread.new(price: 500)
=> #<Bread:0x00005601c8095d38 @attributes=#<ActiveModel::AttributeSet:0x00005601c8095c98 @attributes={"price"=>#<ActiveModel::Attr...
irb(main):019:0> brea.price.with_tax
=> 550.0
irb(main):020:0> brea.price.real_price
=> 500

ActiveModelでの入れ子シリアライズのときに便利

上記のシームレスな型の変換は、シリアライズの時にも便利です。というか、今回はこのニーズがあって、挙動を調べていました。

例えば、以下のような入れ子のActiveModelモデルがあったとして、

class Bread
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :price
  attribute :wheat
end

class Wheat
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :color, :string
end

そのままシリアライズすると、デシリアライズの時に型が戻らず、ハッシュとして復元されてしまいます。

irb(main):001:0> b1 = Bread.new(wheat: Wheat.new(color: 'white'))
=> #<Bread:0x00005556cc140258 @attributes=#<ActiveModel::AttributeSet:0x00005556cc140168 @attributes={"price"=>#<ActiveModel::Attr...
irb(main):004:0> b1.to_json
=> "{\"attributes\":{\"price\":null,\"wheat\":{\"attributes\":{\"color\":\"white\"}}}}"
irb(main):002:0> b2 = Bread.new.from_json(b1.to_json)
=> #<Bread:0x00005556cb9d74d0 @attributes=#<ActiveModel::AttributeSet:0x00005556cb9d7408 @attributes={"price"=>#<ActiveModel::Attr...
irb(main):003:0> b2.wheat
=> {"color"=>"white"}
# ハッシュなので使いにくい!!!!

なので、この場合もwheat型を定義して登録してやると、

# app/models/bread.rb
class Bread
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :price
  attribute :wheat, :wheat
end

# app/models/wheat.rb
class Wheat
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :color, :string

  class Type < ActiveModel::Type::Value
    def cast_value(value)
      if value.is_a?(Hash)
        Wheat.new(value)
      else
        super
      end
    end
  end
end

# config/initializers/types.rb
ActiveModel::Type.register(:wheat, Wheat::Type)

JSON由来のハッシュからのデシリアライズのときにも、適切なバリューオブジェクトの型として戻してくれるので非常に扱いやすいです。

irb(main):001:0> b1 = Bread.new(wheat: Wheat.new(color: 'white'))
=> #<Bread:0x000056235a18a5f0 @attributes=#<ActiveModel::AttributeSet:0x000056235a18a500 @attributes={"price"=>#<ActiveModel::Attr...
irb(main):002:0> b2 = Bread.new.from_json(b1.to_json)
=> #<Bread:0x0000562359b33a08 @attributes=#<ActiveModel::AttributeSet:0x0000562359b33918 @attributes={"price"=>#<ActiveModel::Attr...
irb(main):003:0> b2.wheat
=> #<Wheat:0x000056235a176ed8 @attributes=#<ActiveModel::AttributeSet:0x000056235a176e38 ...
irb(main):004:0> b2.wheat.color
=> "white"