👏

Date型でも年情報のみ(擬似的に)格納できるようにしたい

2023/08/23に公開

Railsで、Date型で保存されているデータをView側で表示する際に、「年のみ表示」「年/月のみ表示」と適宜できるようにしたいという案件があったので、解決方法を考察する。

説明の都合上、商品(Product)の購入日(purchase_date)の表示分けをするという体で話を進める。

前提として、Date型のデータは 2023-08-21 のようにDBに格納されており、
これを 2023 という値で保存することはできない。必ず、月、日の情報までDBに保存される。

問題の解決案

以下の3つの解決策が考えられる。

  • (1) Date型のデータをstring(varchar)型にして、年のみ、年月まで保存、の両方の形式でデータを保存できるようにする

  • (2) 年のみか年月表示なのかを区別するフラグとなる新しいカラムを作り、
    viewで表示する際に毎回フラグをチェックして表示方法を出しわける

  • (3) 年のみか年月表示なのかを区別するフラグとなる新しいカラムを作り、
    該当のメソッド(purchase_date)を、Dateを継承した新しいクラスのオブジェクトを返すようにオーバーライドする。strftime, monthなどのメソッドは年のみ表示の際に間違って使われてしまうと困るので、privateにする。

※次の章から、各案のメリットデメリットを羅列するが、抽象的な話なので実装方法と実際の使い方を見てから読むほうがわかりやすいかもしれない。

(1)の方法のメリットデメリット

メリット

  • フラグを用意するために、新しいカラムを作成する必要がない。

デメリット

  • Dateクラスで実装されているstrftimeなどのメソッドが利用できず、計算するとき等はDateのオブジェクトに変換する必要がある
  • DBに値を保存する際、Date型では当たり前にあったバリデーションがなくなる
  • 年のみ表示、年月表示の2種類のフォーマットが存在し、データの格納方式を自前で考えなければならない
  • 既存のデータを書き換える必要があり、かつ月情報を捨てる場合は不可逆的な変更になる恐れもある

(2)の方法のメリットデメリット

メリット

  • Dateオブジェクトのメソッドを利用することができる(計算等もそのままできる)
  • DBレベルでバリデーションができる
  • 既存のデータを書き換える必要がない

デメリット

  • 日付を表示する際、毎度必ずif分岐が発生する(将来的にif分岐のつけ忘れが発生する可能性が高い)
  • 年のみ表示がしたいのに、monthメソッドが利用されてしまうなどのミスを防ぐのが難しい

(3)の方法のメリットデメリット

メリット

  • (2)と同じ
  • 年のみ、年月表示するかどうかのロジックをモデルで行うため、view側でロジックが膨らまずに済む。

デメリット

  • 実装方法がやや複雑
  • Dateをオーバーライドした新しいクラスのオブジェクトを返すので、それに気づかないと混乱を生む可能性がある

今回の実装では、(3)の方法を採用する。

実装方法

Dateを継承した新しいクラス、今回はpurchase_dateカラムのために用意するので、PurchaseDateクラスと命名して作成する。

app/models/purchase_date.rb
class PurchaseDate < Date
  def initialize(purchase_date, is_only_year)
    super(purchase_date.year, purchase_date.month, purchase_date.day)
    @is_only_year = is_only_year
  end

  # デバッグ時や pメソッドでオブジェクトの内容を表示する際に用いられる。
  def inspect
    "#<PurchaseDate: #{strftime('%Y-%m-%d')}, is_only_year: #{@is_only_year}>"
  end

  # 呼び出し元で、「年月」か「年のみ」両方のフォーマットを指定しないと描画ができないようにする
  # 自身の属性の値に応じて、年月or年のみのどちらかの場合のフォーマット文字列を返す
  def strftime_either(year_month:, only_year:)
    @is_only_year ? strftime(only_year) : strftime(year_month)
  end

  def month_or_nil
    @is_only_year ? nil : month
  end

  private

  # 以下、privateなメソッドには外部から安易に使ってほしくないメソッドを書く。
  def strftime(format)
    super(format)
  end

  def month
    super
  end
end

Productテーブルにbool値を持つ適当なカラムis_purchase_date_store_only_yearを追加する。
(名前が長いのは勘弁してほしい。誤解が無いようにつけようとしたらこうなった。)

db/migrate/xxxxxxxx_add_is_purchase_date_store_only_year_to_products.rb
class AddIsPurchaseDateStoreOnlyYearToProduct < ActiveRecord::Migration[6.0]
  def change
    add_column :products, :is_purchase_date_store_only_year, :boolean, default: false, null: false
  end
end

Productモデルを以下のように実装する。

app/models/product.rb
  def purchase_date
    PurchaseDate.new(super, self.is_purchase_date_store_only_year) if super
  end

purchase_dateカラムはproductテーブルに存在するので、
purchase_dateメソッドもRailsが勝手に実装している。
それをオーバーライドして、普段ならただのDateオブジェクトを返すところを
自前のPurchaseDateオブジェクトを返すようにする。

なので、例えば

  purchase_date = Product.first.purchase_date

このように書くと、DateではなくPurchaseDateオブジェクトが返るため、
以降purchase_date変数で実行されるメソッドはPurchaseDateオブジェクトのメソッドとして実行される。

実際の使い方

もし、解決策(2)を採用していたとすると、例えばコントローラで商品の購入日をstrftimeを使って
インスタンス変数に代入し、ビューで使いたいような場合、

  purchase_date = @product.purchase_date
  @purchase_date_str =
    if @product.is_purchase_date_store_only_year
      purchase_date.strftime('%Y')
    else
      purchase_date.strftime('%Y-%m')
    end

上記のように、strftimeで月まで表示する際は、コントローラ側で毎回条件分岐が発生する。

今回の解決策(3)では、purchase_dateで返る値はPurchaseDateオブジェクトで、
strftimemonthはprivateメソッドにしてあるため、安易に使われることがない。

  @purchase_date_str =
    @product.purchase_date.strftime_either(year_month: '%Y-%m', only_year: '%Y')

  @product.purchase_date.strftime('%Y') # --> error

もちろん、PurchaseDateクラスはDateクラスを継承しているため、privateメソッドとして
オーバーライドしているメソッド以外はDateオブジェクト同様に利用することができる。
+, - 演算子などを使った計算も問題なく可能である。

Discussion