LoginSignup
10
5

More than 5 years have passed since last update.

ActiveRecord の attributes API を使って新しい型を作りたい

Last updated at Posted at 2019-03-06

やりたいこと

  • ActiveRecord の attributes API を使い、 integer/string/date/time などの基本の型をベースに新しい型をつくりたい。
  • 記事中の例としては、 Date をベースとした Birthday (誕生日型)をつくり、そこに age (年齢)メソッドを生やす。
  • 検証バージョンは Rails 5.2.0

背景

# users テーブルには birthday という date 型の属性を持っていることを前提としている.
#
class User < ApplicationRecord
  # ユーザは誕生日の属性を持っている。そして、誕生日から導出可能な年齢を知っている。
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end

このコードの問題点として、例えば User の他に Administrator という似たようなモデルがあり、そちらにも誕生日があったとしたときに似た(そのままの)コードが複数箇所に存在することになりつらいということ。

# users と同様に、 administrators テーブルには birthday という date 型の属性を持っていることを前提としています.
#
class Administrator < ApplicationRecord
  # 同じことを書いてる。。。。。。。。。
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end

or モジュールで書く?

app/models/concerns/age_calculable.rb
module AgeCalculable
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end

User.include AgeCalculable
Administrator.include AgeCalculable

or 誕生日から、年齢を導出するクラス定義を作ってそこに委譲するという手はある。

class AgeCalculator
  def initialize(birthday)
    @birthday = birthday
  end

  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end

  private

  attr_reader :birthday
end
class User < ApplicationRecord
  delegate :age, to: :age_calculator

  private

  def age_calculator
    AgeCalculator.new(birthday)
  end
end

できればこのケースの理想としては 誕生日クラスは年齢を知っている のが良い(※)と思われる。

こんなコードで書きたい

User モデルの birthday 属性は Birthday クラスのインスタンスである。
Birthday クラスは、メソッド age をしゃべることができる。

user = User.new(birthday: Date.new(2000, 1, 1))

# birthday が age を知っている
user.birthday.age  #=> 19

# 上記前提で age メッセージの応答を birthday に委譲するパターン
class User
  delegate :age, to: :birthday, allow_nil: true
end
user = User.new(birthday: Date.new(2000, 1, 1))
user.age  #=> 19

実装

Birthday および Birthday::Type

まずは Birthday クラスを定義し、 ActiveRecord レイヤでのシリアライズおよびデシリアライズ方法を有するクラスも定義する。
Date や Integer など、 ActiveRecord の属性にマッピングされたクラスに委譲するクラスを作ると比較的簡単。そうでない場合は ActiveModel::Type::Value クラスの実装 を咀嚼し、これを継承して上書きすべきメソッド定義を記述することになる。

app/values/birthday.rb
require 'delegate'

class Birthday < DelegateClass(Date)
  # Birthday クラス独自のメソッド記述
  def age(today: Date.current)
    result = today.year - year
    result -= 1 if today < self + result.years
    result
  end

  class Type < ActiveRecord::Type::Date
    def type
      :birthday
    end

    private

    def cast_value(value)
      # 自身の型であるなら dup を返す. そうでない場合は基底クラスに任せたあとその初期化結果をもとに自身のインスタンスを作る.
      case value
      when Birthday then value.dup
      else super.then { |ret| Birthday.new(ret) if ret }
      end
    end
  end
end

ActiveRecord::Type.register により型を登録する

config/initializers/active_record.rb
require 'active_record/type'

Rails.application.config.to_prepare do
  ActiveModel::Type.register(:birthday, Birthday::Type)
  ActiveRecord::Type.register(:birthday, Birthday::Type)
end

使う

# 今回の場合、実際のデータベースの型は基底の date 型で
bundle exec rails g model User birthday:date
app/models/user.rb
class User < ApplicationRecord
  # attribute :birthday, :date
  attribute :birthday, :birthday  # date 型改め birthday 型

  delegate :age, to: :birthday, allow_nil: true
end

できた

user = User.new(birthday: Date.new(2000, 1, 1))
user.age  #=> 19
user = User.new(birthday: '2010-12-31')
user.age  #=> 8

まとめ

  • ActiveRecord (ActiveModel) の attributes API を使って独自の型の属性を作成できる
  • 既存の型を拡張した型を作る場合は既存の型への委譲を前提とすると簡単

※ フレームワークに密に依存しないほうが良いという観点があります。が、ここではとりあえずそういうことにしておいてください :bow:

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5