Active Record の一部カラムをオブジェクトとして切り出す

気づけば随分と間が空いてしまい、ブログを書くのも久々です。ということで今日は小ネタ。 ActiveRecord の一部カラムのみを別オブジェクトとして切り出したいと思ったこと、ありませんか?

例えばこんなコード。

class Item < ApplicationRecord
  # t.string :title
  # t.text :description
  # t.integer :amount
  # t.string :currency
end

class Subscription < ApplicationRecord
  # t.belongs_to :user
  # t.integer :amount
  # t.string :currency

  belongs_to :user
end

item = Item.new(amount: 100, currency: 'JPY')
item.price # => { amount: 100, currency: 'JPY' }

subscription = Subscription.new(amount: 1000, currency: 'USD')
subscription.price # => { amount: 1000, currency: 'USD' }

別テーブルに切り出して正規化するには値の振れ幅が大きいし、何より無意味に has_one なテーブル作りまくってもしょうがなくね?というのが率直な感想で如何ともしがたい。みたいなとき。

もっと言うと今回の Price の例なら僕は validation も書けるようになってほしい。

ということで作りました。こちらです。

partialize.rbapp/models/concerns 以下に、 partial_validator.rbapp/validators の下にでも置いて下さい。これを include すると、こんな書き方が出来ます。

class Price < Partializable::Model
  AVAILABLE_CURRENCY = %w[JPY USD]

  attr_accessor :amount, :currency

  validates :amount, numericality: { greater_than: 0 }
  validates :currency, presence: true, inclusion: { in: AVAILABLE_CURRENCY }

  def currency
    self[:currency]&.upcase
  end
end

class Item < ApplicationRecord
  partialize :price, mapping: { amount: :amount } # 同名のカラムは省略可能
  # partialize :price, class_name: 'Price' # クラス名を与えられる
  # partialize :price, prefix: :price # price_amount, price_currency など、プレフィックスが揃ってるならマッピングはこうも書けます

  validates :price, partial: { if: :persisted? }
end

price = Price.new(amount: 100, currency: 'JPY')
item = Item.new(price: price)
item.amount # => 100
item.currency # => 'JPY'
item.price.amount = 200
item.amount # => 200

ちゃんとオブジェクト経由で書き込んでも元レコードに書き込みされます。安心。別レコードの部分オブジェクトを別レコードの部分オブジェクトとして代入しても、値はコピーされるだけで、参照はちゃんと切れるので、そこも安心です。

こんな感じで使えると便利だなーって感じで作ったんですが、いかがでしょう。評判良さそうなら gem 化したいな。名前が微妙すぎるから、違う名前つけてあげたいけど……