Railsで静的データのモデルの実装

## 最初に

本記事でやりたかったことは [active_hash](https://github.com/active-hash/active_hash) を使ってもできるので、その前提でお読みいただけると。

記事を書いたときは `active_hash` を知らず、コメントで教えていただきました。

## 背景
Ruby on Railsで開発をしていて、
更新性の低いデータ、DB管理する必要ないであろうデータの扱い方に迷ったので、
対応方法を残します。

## 対象データ
Webサービスのプラン情報についてです。
基本的にリリース後に追加はあっても変更する可能性は低いです。
追加に関してもあっても2年に一度とかとかになるかな..と考えています。

ですので、管理画面作って変更するまでもないし、
DBに入れるまでもないデータだなと思ってどのように実装するかを迷っていました。

## 対処方法
Modelとしては実装しつつも、
DBには保存せず静的なデータで定義をしました。

## 実際のコード

以下のModelクラスを実装です。

```models/plan.rb
class Plan
  attr_reader :name, :display_name, :amount, :member_count_limit, :document_export_count_limit,
              :document_count_limit, :document_version_count_limit, :document_version_amount_limit,
              :document_variable_count_limit, :enable_api

  def self.all
    [FREE, TEAM, BUSINESS]
  end

  def self.find_by_name(name)
    all.find { |plan| plan.name == name }
  end

  def self.find_by_name!(name)
    find_by_name(name) || raise(ArgumentError, "Plan with name '#{name}' not found")
  end

  private

  def initialize(attributes = {})
    attributes.each do |attr_name, attr_value|
      instance_variable_set("@#{attr_name}", attr_value)
    end

    validate_attributes
  end

  def validate_attributes
    instance_variables.each do |attribute|
      attribute_name = attribute.to_s[1..].to_sym # @name => name
      validate_presence_of(attribute_name, send(attribute_name))
    end

    # FIXME: nameのユニークチェックを行う
    # uniquenessのバリデーションは、allに依存するため、initialize後に行う必要がある
    # 良い実装が思いつかなかったので、一旦Unitテストで保証するのみとする
  end

  def validate_presence_of(attribute, value)
    # valueがbooleanの場合は nil? で判定するとfalseがnilと判定されてしまうため、nil? で判定しない
    return unless value.respond_to?(:empty?) ? value.empty? : value.nil?

    raise ArgumentError, "#{attribute} is required"
  end

  FREE = Plan.new(
    name: 'free',
    display_name: 'フリー',
    amount: 0,
    member_count_limit: 2,
    document_export_count_limit: 20,
    document_count_limit: 10,
    document_version_count_limit: 3,
    document_version_amount_limit: 10.megabytes,
    document_variable_count_limit: 20,
    enable_api: false
  )

  TEAM = Plan.new(
    name: 'team',
    display_name: 'チーム',
    amount: 500,
    member_count_limit: 10,
    document_export_count_limit: 200,
    document_count_limit: 50,
    document_version_count_limit: 10,
    document_version_amount_limit: 20.megabytes,
    document_variable_count_limit: 30,
    enable_api: true
  )

  BUSINESS = Plan.new(
    name: 'business',
    display_name: 'ビジネス',
    amount: 3000,
    member_count_limit: 50,
    document_export_count_limit: 1_000,
    document_count_limit: 100,
    document_version_count_limit: 20,
    document_version_amount_limit: 50.megabytes,
    document_variable_count_limit: 100,
    enable_api: true
  )

  private_class_method :new
end
```

テストはこんな感じ。

```models/plan_spec.rb
RSpec.describe Plan, type: :model do
  describe 'コンストラクタ' do
    it '外部からのインスタンス生成ができないこと' do
      expect { Plan.new }.to raise_error(NoMethodError)
    end
  end

  describe '属性' do
    it 'nameがユニークであること' do
      expect(Plan.all.map(&:name)).to eq Plan.all.map(&:name).uniq
    end

    it '変更できないこと' do
      expect { Plan::FREE.name = 'hoge' }.to raise_error(NoMethodError)
    end
  end

  describe '.all' do
    it '全てのプランを返すこと' do
      expect(Plan.all).to match_array [Plan::FREE, Plan::TEAM, Plan::BUSINESS]
    end
  end

  describe '.find_by_name' do
    context '存在するプラン名を指定した場合' do
      it '指定したプラン名に対応するプランオブジェクトを返すこと' do
        expect(Plan.find_by_name('free')).to eq Plan::FREE
        expect(Plan.find_by_name('team')).to eq Plan::TEAM
        expect(Plan.find_by_name('business')).to eq Plan::BUSINESS
      end
    end

    context '存在しないプラン名を指定した場合' do
      it 'nilを返すこと' do
        expect(Plan.find_by_name('nonexistent')).to be_nil
      end
    end
  end

  describe '.find_by_name!' do
    context '存在するプラン名を指定した場合' do
      it '指定したプラン名に対応するプランオブジェクトを返すこと' do
        expect(Plan.find_by_name!('free')).to eq Plan::FREE
        expect(Plan.find_by_name!('team')).to eq Plan::TEAM
        expect(Plan.find_by_name!('business')).to eq Plan::BUSINESS
      end
    end

    context '存在しないプラン名を指定した場合' do
      it 'ArgumentErrorを発生させること' do
        expect do
          Plan.find_by_name!('nonexistent')
        end.to raise_error ArgumentError, "Plan with name 'nonexistent' not found"
      end
    end
  end
end
```

## 意識したポイント

### 他のクラスからの変更への対応

newをプライベートにして、属性もRead Onlyにしているため、
外部からプラン情報を差し替えたり変更することができない作りにしました。

### ActiveRecordライクなインターフェース

RailsのModelといえば、そう、ActiveRecord。
なので、インターフェースを合わせることを少し意識しました。
ただ、同じ使い方ができるわけではないので注意が必要です。

## 迷ったポイント

### 設定を定義する場所

今回はモデル内で `FREE`, `TEAM`, `BUSINESS` の設定情報を持ちました。
これを外部のファイルに出すかを迷いましたが、
少ない手数での実装 & 見通しの良さを優先しました。

### 属性のユニークチェック

コード内にFIXMEを埋め込んでいます。
複数の方法でユニークチェックは動作しましたが、
テストでカバーする方針に落ち着きました。

## 実装してみての感想

やはり、一般的なModelとは違う作りになってしまったので、Railsの恩恵をあまり受けられていません。
運用まで考えると、今回の設計（DB保存しない）がベターだと思います。
ただ、Railsを使うのであればDBにデータ保存して Easy に実装するのもアリだなと思いました。