ActiveRecord::EnumのI18n

ActiveRecordのenumにI18n用の機能を提供するgemをつくった。

https://github.com/r7kamura/activerecord-enum_translation

どういうものか

Userがenumを利用したstatusというカラムを持っているとすると、I18n用の辞書としてこういう風なデータを用意しておけば…

ja:
  activerecord:
    attributes:
      user:
        name: 名前
        status:
          active: 利用中
          inactive: 停止中

次のようにメソッドを呼び出すことで、翻訳された辞書が利用できるようになる、というやつ。

user.human_enum_name_for(:status) #=> "利用中"

技術的な難題の解決とかは全く狙っていなくて、どちらかと言うと、この手の仕組みにルールが無くてアプリ内のいろんなところにこれ系のいろんな実装が書かれがちなので、決まりを用意して仕組み化しましょうというのだけを狙って書いてみたところが大きい。

ある程度コード量が増えても良いので明示的にやる

同じ問題を解決するためのgemとして、昔からenum_helpというものがあるので、これを使っても良い。あとここでは紹介しないけれど似たようなものが幾つかあります。

https://github.com/zmbacker/enum_help

解決策としてもちろん知ってはいたのだけれど、個人的にはあまり気に入らなくて、別のものを自作してしまった。せっかくだから、気に入らなかったところなどについて書いてみる。

例えば、何もしなくても済むようにデフォルトでわりと色々な機能を用意してくれる結果、コード中に足掛かりが何も生まれないところとかは、長年さまざまなRailsアプリの面倒を見てきた結果、かなり嫌いな要素になっている。

enum_helpも、Railtieを利用して、ActiveRecord::Baseに勝手にmoduleをincludeして、enum を上書きして、enum利用時に勝手に便利メソッドが追加で生える…という、まあRails用のプラグインとしてはよくある仕組みで実装されているのだけど、できる限りこういうのも明示的にやりたい。

ライブラリ側はmoduleを提供するので、ユーザにはそれをincludeしてもらって、includeするということはインスタンスメソッドが提供されるということが想定できて、想定通り単純で素直なインスタンスメソッドが提供される。それも何かマジカルな仕組みで動的に定義されたメソッドとかではなく普通のものが提供される。そういう単純な感じであってほしい。

とはいえ便利なメソッドが動的に定義される方が便利やん、という意見があり、例えば前述のコードは以下のように引数の不要な形で書けるともっと嬉しい。

user.human_enum_name_for_status #=> "利用中"

今回書いたやつでは、こういうのが欲しいときは human_enum_reader_for というメソッドでわざわざ宣言させるようにしている。まあこの特異メソッドが呼び出せるようにするために、結局include時に若干マジカルなことをしてはいるんだけども。

class User < ApplicationRecord
  enum status: %i[active inactive]
  human_enum_reader_for :status
end

こういう風に明示的にしておくと何が良いかというと、例えばこの機能から撤退しようということになったときに、「アプリ内の全てのこの機能について使われてるかどうか調べなきゃいけないじゃん……ほぼ無理だろ……」と途方に暮れる可能性が少しだけ減って、使われてそうなところに当たりがつけられるようになるので、少し希望的になる。今回の例について言えばまだなんとかなったりする範疇なので、言いたいことの例としてあまり良いものではないんだけど、まあなんか言わんとしてることは伝わってほしいです。

既存の翻訳辞書との兼ね合い

他に気になるところとして、既存の翻訳辞書に新たにenum用の値を追加するときに、例えばこういう定義場所が離れるような感じにしたくないという気持ちが少しあった。

ja:
  activerecord:
    attributes:
      user:
        name: 名前
  enums:
    user:
      status:
        active: 利用中
        inactive: 停止中

これはかなり単純化された例なので、実際にはattributesのところに既存の項目が沢山並んでいて、実際にはenum用のデータはかなり遠く離れたところに置かれることになる感じ。

まあそれでも賛否両論あると思っていて、既存の辞書内にenum用の値を上手く忍ばせているせいで、逆にこのgemを廃止してenum用のデータだけ取り除きたくなったときに「もはやenum用のものかどうか分からん状況になっとるやんけこいつセンスねえな」と思うケースもあるかも。

普段Railsのコード読んでて勘の良い人なら実装読むと分かると思うけど、今回書いたやつはほぼActiveModel::Translationの仕様と実装に寄せて書いている。

仕様についてどの辺が寄せられているかというと、(READMEには書いてないけど) 例えば全てのモデルについてのデフォルト値を書きたい場合は以下のように書けたりもするし、

ja:
  attributes:
    status:
      active: 利用中

直接のクラス名について該当する翻訳が無かった場合は親クラスに遡ったりもするし、何も無かった場合は String#humanize で代替の値が用意されたりするし、defaultオプションでその値を外から指定できたりもする。