Posted at

ActiveModel::Serializersの痒い所に手が届く使い方


はじめに

ここ半年ぐらいActiveModel::Serializersを使った開発をしていて

痒い所に手が届く書き方をいくつか見つけたので紹介していきます

基本的な使い方はすでにいろんな方の記事があるので省略します


1. キー名を変えたい


  • id

  • first_name

  • last_name

  • employee_number

といったカラムを持つUserモデルがあったときに

first_namenameとして出力したい場合


a. :keyオプションを使う

class UserSerializer < ActiveModel::Serializer

attributes :id, :employee_number
attribute :first_name, key: :name
end


b. メソッドを定義する

class UserSerializer < ActiveModel::Serializer

attributes :id, :name, :employee_number

def name
object.first_name
end
end


c. ブロックで渡す

class UserSerializer < ActiveModel::Serializer

attributes :id, :name, :employee_number

attribute :name do
object.first_name
end
end

※objectはSerializernewなどで渡されたオブジェクト(レコード)自身



  • attributeだけでなくbelongs_to has_one has_manyでも同様の使い方が可能

  • メソッドやブロックは処理も書けるので分岐や加工がしやすくて便利


2. has_manyで紐づくモデルをSerializerの中でorderするとSQLキャッシュのN+1が起きる問題をなんとかしたい


before

class User < ActiveRecord::Base

has_many :posts
end

class Post < ActiveRecord::Base
belongs_to :user
end

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :employee_number
has_many :posts, serializer: PostSerializer do
object.posts.order(created_at: :desc)
end
end


と書くと事前にpreloadなどをしてN+1対策をしていても

CollectionSerializerで大量に出力する際にSQLキャッシュのN+1が発生します

ログの中にCACHE (0.0ms) SELECT ....みたいなのが大量に出て

キャッシュされているとはいえやはり遅くなる

そんな時の対策はこちら


a. モデルのアソシエーションにデフォルトのオーダーを仕込む


after

class User < ActiveRecord::Base

has_many :posts, -> { order(created_at: :desc) }
end

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :employee_number
has_many :posts, serializer: PostSerializer
end


としてやると最初にオーダーされてキャッシュのN+1が発生しません

ただ、これだと他のところでこのorderが効いて欲しくない時があるかも

そんな時はモデルにもう一個別のアソシエーションを定義します


b. モデルにオーダー用のアソシエーションを定義する


after

class User < ActiveRecord::Base

has_many :posts
has_many :for_order_posts, -> { order(created_at: :desc) }, class_name: :Post
end

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :employee_number
has_many :for_order_posts, serializer: PostSerializer
end


もちろんpreloadでのアソシエーションも:for_order_postsに直します

個人的にはこれが好きです


3. has_manyのアソシーションの最新1件を含めたいけどこれまたSQLキャッシュのN+1が起きる問題


before

class User < ActiveRecord::Base

has_many :posts
end

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :employee_number
attribute :newest_post, serializer: PostSerializer do
object.posts.order(updated_at: :desc).limit(1)
end
end


ってやるとやっぱりSQLキャッシュのN+1が発生するので

やっぱりここも別のアソシエーションを定義します


a. has_onescopeをモデル側に作る


after

class User < ActiveRecord::Base

has_many :posts
has_one :newest_post, -> { order(updated_at: :desc) }, class_name: :Post
end

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :employee_number
attribute :newest_post, serializer: PostSerializer
end


has_oneなので勝手にlimit(1)がかかります


4. attributeで出力するか否かの条件をつけたい


a. :ifオプションを使う

class UserSerializer < ActiveModel::Serializer

attributes :id, :name, :employee_number
attribute :name, if: -> { object.has_attribute?(:name) }
end

ってやるとnameカラムが存在するときだけ出力してくれたり

ポリモーフィックで

UserモデルがChildBoyChildGirlというモデルをChildという名前で持っていた時に

ChildBoyの時はChildGirlを出さない、みたいな使い方もできます


5. おまけ(入れ子になったSerializerのテストコード)

当たり前かもしれませんが自分はいつも入れ子になったSerializerのrspecを書く時はいつもこうやってます

it 'serializes User' do

expect(UserSerializer.new(user).to_json).to be_json_including(
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
posts: user.posts.map do |post|
JSON.parse(PostSerializer.new(post).to_json)
end
)
end

もちろんPostSerializerがテストされている前提です

PostSerializer.new(post).to_hashでもいいんですが

jsonに変換した時に日時のフォーマットが変わっちゃったりするのでJSON.parseしてます