ぶちのブログ

競プロとCTFが趣味なWebエンジニアのアウトプットの場

RailsのEnumで、「その他」をいい感じに扱ってみた & gemも作ってみた

環境

Ruby 2.6.0, Rails 5.2.2

 

やりたかったこと

本来は自由記述であったフォームを、データ分析の観点からenumに書き換えたかった。一方で、enumでは拾いきれない外れ値も保存しておきたかった。  

フォームを分けると、「その他」を選択した際と選択しなかった際、それぞれのバリデーションが問題になるので、データ分析しづらいレコードが混入しないように気をつける必要があった。  

JSでフォームを動的に生成するのは、使いたい環境ではやや手間だったので、Railsのみで実装した。  

以下は作りたかったフォームのイメージ。

f:id:betit0919:20190319235438p:plain

設計

大きく分けて「カラムを分けない」「カラムを分ける」という、2つの設計が考えられました。それぞれ以下のような特徴があると考えられます。

カラムを分けない

メリット

  • テーブル定義をスッキリしたままに保てる。

デメリット

  • いい感じのhelperメソッドがない。
  • アプリケーション側の可読性が下がりそう。

カラムを分ける

メリット

  • Railsのレールに乗ることができそう。

デメリット

  • カラムが増え、テーブル定義が複雑になってしまう。

今回はカラムを分けることにしました。

実装

マイグレーションファイル

create_table :hoge do |t|
  t.integer :reason
  t.string  :other_reason
end

モデル

class Hoge < ApplicationRecord
  validates :other_reason, fuga: true  

  enum reason: {
    foo: 10,
    bar: 20,
    other_reason: 1000
  }
end

ビュー

<div>
  <%=
    form.select :reason,
    Hoge.reasons.keys    
  %>
</div>
<div>
  <%=
    form.text_field :other_reason
  %>
</div>

バリデータ

class FugaValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return unless record.send("#{attribute.to_s[6..-1]}?")

    if record.send(attribute.to_s + '?')
      record.errors.add(attribute, :blank) if value.blank?
    elsif value.present?
      record.errors.add(attribute, :present)
    end
  end
end

バリデータにロジックを押し込めて、疎結合を実現しようとしています。しかし、バリデータがモデルについて知りすぎている(特にattribute.to_s[6..-1]のあたりが残念)感があります。validate_eachの引数を変えられない以上は、エラーハンドリングでいい感じにするしかなさそうでした。

なお、複数カラムにまたがるvalidationなので、validatesではなくvalidateを使うべきなのかと、かなり悩みました。が、抽象化することを見越すならば、validatesメソッドを使った方が見通しが良くなりそうでした。(validateメソッドを使った場合は、Validatorの共通化がかなり難しそうでした)

gem作成

Moduleにするなら、ついでにgemを作ってしまおうと考えました。特に大きく変えた部分はありませんが、名前空間については注意が必要でした。 validatesメソッドの定義を見る限りでは、名前空間を切っていても動きそうなのですが、

"hoge_hoge::fuga_fuga".camelize  # => "HogeHoge::fugaFuga"

となってしまい、validatorを探しに行ってくれません。少し気持ち悪いですが、

validates other_hoge, "HogeHoge::FugaFuga" => true

とするしかなさそうでした。(この例だとHogeHoge::FugaFugaValidatorを呼んでくれます)

実装は終わっていたので、リファクタリング+gemのリリースまでで30分程度で終わりました。

ソースコードこちらにあります。参考になるかはわかりませんが、よろしければご覧ください。