サブジェクトが長い。
tl;dr
- Attribute APIにはカスタムクラスを登録できるのでバリューオブジェクトを使うのに便利
- 宣言に合わせて自動で変換してくれるので、デシリアライズのときにも便利
- バリューオブジェクトを積極的に使っていきたい
ActiveRecord Attribute APIの利用
ActiveRecordにはAttribute APIというのがあって default
や limit
が指定できて便利です。 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"