Edited at

RailsのEnumで複数項目を選択可能にするGemを作ってる話


つまり・・・どういうこと?

一言で言うと、「enumで複数項目を選択できる風にするRails Plugin」。

リポジトリ(未完成。gemには登録してないので、installするにはgithubから)

https://github.com/EastResident/any_enums

何を言ってるのか分からないと思うので、以下のサンプルコードで雰囲気を感じて下さい

class Engineer < ApplicationRecord

include AnyEnums
any_enums field: { server: 1, front: 2, infra: 3 }
end

engineer = Engineer.server_or_front.new

p engineer.field # => "server_or_front"
p engineer.fields # => ["server", "front"]


開発思想

例えば、 ApplicationRecordを継承するEngineerモデルがあるとしましょう。Engineerモデルはfieldと言う属性を持っており、これはserver、front、infraのいずれかの値が入ります(説明するまでもないですが、大雑把な専門分野のイメージです)。

Engineerのインスタンスはfieldを一つだけではなく、複数持つ場合もあるとします(フルスタックエンジニアとかあるしね)。

このようなデータ構造を持たせる場合、一般的にRailsなら中間テーブルを挟んだ多対多のリレーションを作るかもしれません。

class Engineer < ApplicationRecord

has_many :engineer_fields
has_many :fields, through: :engineer_fields
end

class Field < ApplicationRecord
has_many :engineer_fields
has_many :engineers, through: :engineer_fields
end

class EngineerField < ApplicationRecord
belongs_to :engineer
belongs_to :field
end

field属性が今後も増えていくなら、この構造は合理的だと思います。

しかし、field属性の種類が今後も増える予定がなく、3項目のままだとしたらどうでしょうか?

Engineerモデルにfield属性を持たせるために、モデル(とテーブル)を二つも増やすのは少し大げさな気もします。

このようなケースを解決する選択肢の一つを提供するのが、このany_enumsです。


使い方


定義

普通、enumは以下のように定義すると思います。


普通のenum定義

class Engineer < ApplicationRecord

enum field: { server: 1, front: 2, infra: 3 }
end

any_enumsでは、これを以下のように変更します。


any_enumsの定義

class Engineer < ApplicationRecord

include AnyEnums
any_enums field: { server: 1, front: 2, infra: 3 }
end

AnyEnumsをincludeして、enumをany_enumsに置き換えるだけですね。


インスタンスの作成

例えば、通常のenumでfieldがserverのEngineerモデルのインスタンスを作成するには以下のようにします。

engineer = Engineer.server.new

engineer.save

そして、fieldの値を取得する場合はこんな感じですね。

p engineer.field # => "server"

p engineer.field_before_type_cast # => 1

では次にany_enumsの機能を使って、serverとfront両方をfieldに持つEngineerのインスタンスを作成します。

engineer = Engineer.server_or_front.new

engineer.save

ここでengineerインスタンスは、fieldメソッド・field_before_type_castメソッドの他に、fields・fields_before_type_castメソッドが使えるようになっています

p engineer.field # => "server_or_front"

p engineer.fields # => ["server", "front"]

p engineer.field_before_type_cast # => 110
p engineer.fields_before_type_cast # => [1, 2]

先ほどインスタンスを作成する際に、server_or_front.newとしましたが、これはfront_or_server.newとしても動作します(ただし、enumの定義順にorで繋ぐことが推奨されます)。

どちらの方法でインスタンスを作成しても、fieldに入る値は同じになります。


宣言はどちらでもOKだが、入る値は同じ

engineer = Engineer.server_or_front.new

engineer = Engineer.front_or_server.new


仕組み

前項で使い方を説明した際に、以下のような記述をしました。

engineer = Engineer.server_or_front.new

p engineer.field # => "server_or_front"
p engineer.field_before_type_cast # => 110

お気付きだと思いますが、any_enumsは裏側で{server_or_front: 110}と言うような割り当てを行っています。では、どのようなルールでserver_or_frontが110に割り当てられたのでしょうか?

any_enumsは独自のハッシュテーブルを保持しており、0〜9の整数からなる全ての組み合わせに対して、101以上の整数を割り当てています。

[0, 1]=>101,

[0, 2]=>102,
[0, 3]=>103,
〜〜
[4, 6, 7, 8]=>467,
[4, 6, 7, 9]=>468,
〜〜
〜〜
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] => 1113

御察しの通り、上記の機構を持つ理由からany_enumsを使用するには制限があります。

まず、any_enumsには0から9までの整数しか割り当てられません。また、101から1113までの整数が自動的に割り当てられる可能性があるため、割り当てが非推奨になります。このような仕様にした理由としては、自分の経験から、一つのカラムに対して10以上の値を列挙することはないと判断したからです(経験上、多くて5〜6個)。


今後の展望


  • formで利用するview helperの作成

  • 通常のenumで使える機能を可能な限り再現