Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: PORO の attribute に対して ActiveRecord::Enum 風の実装をする

先日業務中に遭遇したちょっとした疑問から ActiveRecord::Enum っぽい実装を POROで再現するアレコレを試してみたくなったのでこの記事を書き起こしてみました。
enum についてはそもそも ActiveRecord::Enum の宣言の仕方からして色々なパターンがあったり、PostgreSQL での enum 型の存在もあったりで Enum 全般での解説、深掘り記事もいつか書いてみたい気持ちもあるのですが、今回は思い立ったが吉日と言うことで、あえて何故かいきなり王道の話題から外れたところに話を絞っていきたいと思います。
(ところで校正してる時に気付きましたが ActiveModel::Enum を導入してみようとする試みとそれを拒否した議論の流れとかは Rails 本体では当然のごとく既に通ってきた道のようでした)

なお、本記事は色々な実装の方向性を試行錯誤してみた結果のメモが中心になっています。
本記事内容通りの細かい設計や実装内容を真似しろ!と言う布教やベストノウハウの記事ではありませんので悪しからず。

下準備

毎回 Rails の記事書こうかなと思い立ったは良いものの......
そもそも今流の rails new の仕方が分からねぇ!って言うのがマジで一番の障害だったりします。頑張って乗り越えました。のでこの時点で個人的価値がある。
せっかくなので今回 rails-new を使おうとしたら手元のWSL2のUbuntu環境が古すぎてインストールしても使えませんでした 😇
その内新しいUbuntu導入したらリベンジします......!!

そんなわけで超雑に流してとりあえずrails new直後の画面が見れるのさえ確認できたら良いやで割愛。適当にDockerで以下のような環境を用意しました。

  • Ruby: 3.3.6
  • Rails: 8.0.1
  • Postgres: 16.6

例によって GitHub 内に大体のソースコードを置いておくので良ければご活用ください。

まずは「Testing Rails Applications」の流れにでも沿って適当なモデルを作ります。

root@9f7de4d47204:/app# bin/rails g model article title:string body:text status:integer custom_fields:jsonb
      invoke  active_record
      create    db/migrate/20241218081719_create_articles.rb
      create    app/models/article.rb
      invoke    test_unit
      create      test/models/article_test.rb
      create      test/fixtures/articles.yml

migration はもうちょい整えます。こんな感じで。

class CreateArticles < ActiveRecord::Migration[8.0]
  def change
    create_table :articles do |t|
      t.string :title, null: false
      t.text :body, null: false, default: ""
      t.integer :status, null: false, default: 0
      t.jsonb :custom_fields, null: false, default: {}

      t.timestamps
    end
  end
end

こんな感じの Article と言うモデルをこれから取り扱ってみることにします。

  • title : 記事のタイトル
  • body : 記事の中身
  • status : 記事のステータス。 enum で取り扱えるものとします。
    • draft : 下書き
    • publish : 公開
    • privated : 非公開
  • custom_fields : その他のデータ突っ込む JSON
    • author : 著者名。シンプルな文字列の記入例が欲しかっただけ。
    • category : 記事のカテゴリー。このシステムではカテゴリーは一つしか持たないものとし、これを enum で取り扱ってみます。
      • rails-related : Rails関連
      • not-rails-related : Rails関連以外
    • tags : 記事のタグ。これは複数付けられるArray型としてみましょう。

まずは ActiveRecord::Enum の使用感を再確認する

基本的には ActiveRecord::Enum の説明を見ろと言う話ではありますが、色々と改めて実際の挙動を確認してみます(Railsのバージョンやconfigの状態によって細かい挙動に差が出るかもしれない)。
そんな基礎は分かっとるわい!な人は読み飛ばしてください。

例えば、今回は次のような enum の定義をすることにします。

class Article < ApplicationRecord
  enum :status, { draft: 0, publish: 1, privated: 10 }, suffix: true
end

attribute のセットに関して

  • DB定義側に default 値の指定があればインスタンスの initialize 後には default 値がセットされる
app(dev)> Article.new
=> #<Article:0x00007f8119e10108 id: nil, title: nil, body: "", status: "draft", custom_fields: {}, created_at: nil, updated_at: nil>
  • assign_attributes などで attribute をセットする際にはDBに保存する生の値でも、意味を込めた値である文字列やシンボルでもセットできる
app(dev)> Article.new(status: 10)
=> #<Article:0x00007fa86dfe0a20 id: nil, title: nil, body: "", status: "privated", custom_fields: {}, created_at: nil, updated_at: nil>
app(dev)> Article.new(status: :privated)
=> #<Article:0x00007fa86c17ed98 id: nil, title: nil, body: "", status: "privated", custom_fields: {}, created_at: nil, updated_at: nil>
app(dev)> Article.new(status: "privated")
=> #<Article:0x00007fa86c177598 id: nil, title: nil, body: "", status: "privated", custom_fields: {}, created_at: nil, updated_at: nil>
  • 空文字や nil をセットした時は nil になる
app(dev)> Article.new(status: "")
=> #<Article:0x00007fb5e2ec4880 id: nil, title: nil, body: "", status: nil, custom_fields: {}, created_at: nil, updated_at: nil>
app(dev)> Article.new(status: nil)
=> #<Article:0x00007fb5e2eee248 id: nil, title: nil, body: "", status: nil, custom_fields: {}, created_at: nil, updated_at: nil>
  • 不正な値をセットしようとした場合は ArgumentError が生じる
app(dev)> Article.new(status: 123)
(app):15:in `<top (required)>': '123' is not a valid status (ArgumentError)

          raise ArgumentError, "'#{value}' is not a valid #{name}"
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
app(dev)> Article.new(status: "hogehoge")
(app):16:in `<top (required)>': 'hogehoge' is not a valid status (ArgumentError)

          raise ArgumentError, "'#{value}' is not a valid #{name}"
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
app(dev)> Article.new(status: :hogehoge)
(app):17:in `<top (required)>': 'hogehoge' is not a valid status (ArgumentError)

          raise ArgumentError, "'#{value}' is not a valid #{name}"
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

attribute の読み出しに関して

  • attribute を呼び出すと文字列が返って来る
app(dev)> Article.new.status
=> "draft"
  • DBに保存されている生の値を確認したい場合は attribute_before_type_cast
    (今回の例なら status_before_type_cast )を用いる
app(dev)> Article.new.status_before_type_cast
=> "0"
  • 現在の enum 値の判定をする boolean メソッドが動的に定義される。今回の例では suffix: true のオプションを渡したので各 status の定義が suffix になっている
app(dev)> Article.new.draft_status?
=> true
app(dev)> Article.new.publish_status?
=> false
app(dev)> Article.new.privated_status?
=> false
  • I18n の辞書定義の呼び出し......
    は完全にいつもの使用感が enum_help gem に汚染されてるなぁ。
    今回はなるべくバニラな実装を意識してみましょうか。
ja:
  activerecord:
    models:
      article: 記事
    attributes:
      article:
        title: タイトル
        body: テキスト
        status: ステータス
        custom_fields: カスタムフィールド
      article/status:
        draft: 下書き
        publish: 公開
        privated: 非公開

こう言う定義があったなら、こんな感じで日本語表現を呼び出しできますね。

app(dev)> article = Article.new
=> #<Article:0x00007fb5e3bac2f0 id: nil, title: nil, body: "", status: "draft", custom_fields: {}, created_at: nil, updated_at: nil>
app(dev)> article.class.human_attribute_name("status.#{article.status}")
=> "下書き"

元になる ActiveRecord::Enum がある場合の ActiveModel 上での enum 型の表現

ここからが本番です。まぁ要するにフォームオブジェクトとかを導入してみる場合を考えるわけです。
例えば、ベースとしてはこう言うの。実用性が全くない実装例なのはお気になさらず。

class ArticleForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :title, :string
  attribute :body, :string
  attribute :status, :integer
  attribute :custom_fields, :string
end

attribute のセットに関して

先に見て来たとおり ActiveRecord::Enum を取り扱う時のセッターは柔軟性が非常に高いです。
一方で ActiveModel の一attributeとして定義する場合は、ユースケースとかに沿ってセットのされ方を一意に決めることになるとは思います。

デフォルト値のセット

当然ながら PORO の場合は何もやってないと何も設定されずこうなる。

app(dev)> ArticleForm.new.attributes
=> {"title"=>nil, "body"=>nil, "status"=>nil, "custom_fields"=>nil}
  • 一番シンプルな話としては attribute に対しては ActiveRecord 同様に default の定義ができるためそれを利用する
    つまり attribute :status, :integer, default: 0 のように attribute を定義すればこうなる
app(dev)> ArticleForm.new.attributes
=> {"title"=>nil, "body"=>nil, "status"=>0, "custom_fields"=>nil}

ただし、この場合も空文字などが渡ると default 値は上書きされるので注意が必要(逆に言うと .compact_blank とか噛まして渡せば default 値に寄せられるはず)。

app(dev)> ArticleForm.new(status: "").attributes
=> {"title"=>nil, "body"=>nil, "status"=>nil, "custom_fields"=>nil}

また integer 型への変換が働くことによって to_i の処理で不正な文字列が 0 に変換されることが起きたりする。これも地味に使い勝手が悪くなるので注意。

app(dev)> ArticleForm.new(status: "hoge").attributes
=> {"title"=>nil, "body"=>nil, "status"=>0, "custom_fields"=>nil}

ActiveRecord::Enum の方では status_before_type_cast のメソッドが定義されていましたが、このメソッドは ActiveRecord::AttributeMethods::BeforeTypeCast として定義されています(少し前にこのモジュールを ActiveModel 下に移そうとする PR が一度マージされてますattribute_for_database などのメソッド定義も本モジュール内に実装されてたこともありrevertされてます)。

そのため ActiveModel::Attributes としてこのメソッドは使えないものの ActiveModel::Attribute::WithCastValue のインスタンス変数として @value_before_type_cast は参照できるので頑張れば参照できます。

app(dev)> ArticleForm.new(status: "hoge").instance_variable_get(:@attributes)["status"].value_before_type_cast
=> "hoge"
  • ActiveRecord では default 値がセットされるわけなので ActiveRecord のインスタンスを経由できるなら ActiveRecord での default 値定義をそのまま生かせることもあるかもしれないが、それができないから PORO を導入してるかもしれない
app(dev)> ArticleForm.new(Article.new.attributes.except("id", "created_at", "updated_at")).attributes
=> {"title"=>nil, "body"=>"", "status"=>0, "custom_fields"=>"{}"}

バリデーションの定義

attribute :status, :integer の定義だけでは enum として想定されていない不正な値もセットできてしまう。
validates :status, presence: true, numericality: { only_integer: true }, inclusion: { in: [0, 1, 10] } などのバリデーションを定義することで valid? の評価は一応できるようになる。

app(dev)> ArticleForm.new(status: 2).valid?
=> false
app(dev)> ArticleForm.new(status: 2).tap { |f| f.valid? }.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=status, type=inclusion, options={:value=>2}>]>
app(dev)> ArticleForm.new(status: 10).valid?
=> true

DBの生の値や、シンボル値ではなく、日本語でセットしたい

CSVインポートの処理とかだとこう言う仕様になりがち。
I18nの辞書定義を確認すれば以下のようなkey/valueの組み合わせが取れる。

app(dev)> I18n.t("activerecord.attributes.article/status")
=> {:draft=>"下書き", :publish=>"公開", :privated=>"非公開"}
  • どうにかしたいのがセッターだけの問題であれば以下のように初めから元になるモデル側とは別の attribute の定義にしてしまえば
class ArticleForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :title, :string
  attribute :body, :string
  attribute :status_i18n, :string
  attribute :custom_fields, :string

  def to_model
    to_model_attributes = attributes.except("status_i18n").merge(status: status)
    Article.new(to_model_attributes)
  end

  private

  def status
    I18n.t("activerecord.attributes.article/status").invert.fetch(status_i18n)
  end
end

以下のような形で ActiveRecord のインスタンスを作る際にセットできる。定義されてない値を fetch すると KeyError が発生するので元の ActiveRecord の使い勝手に少し寄っている気がしないでもない。

app(dev)> ArticleForm.new(status_i18n: "公開").attributes
=> {"title"=>nil, "body"=>nil, "status_i18n"=>"公開", "custom_fields"=>nil}
app(dev)> ArticleForm.new(status_i18n: "公開").to_model
=> #<Article:0x00007fd20a808de0 id: nil, title: nil, body: nil, status: "publish", custom_fields: nil, created_at: nil, updated_at: nil>
app(dev)> ArticleForm.new(status_i18n: "ほげ").attributes
=> {"title"=>nil, "body"=>nil, "status_i18n"=>"ほげ", "custom_fields"=>nil}
app(dev)> ArticleForm.new(status_i18n: "ほげ").to_model
app/models/article_form.rb:19:in `fetch': key not found: "ほげ" (KeyError)
        from app/models/article_form.rb:19:in `status'
from app/models/article_form.rb:12:in `to_model'
        from (app):16:in `<top (required)>'
  • attribute の型を増やして attribute :status, :article_status を定義する方法もできなくはないが(詳しくは「Creating Custom Types」 を読んで欲しい)ユースケースがあまりにもガチガチの性質のものに対して定義するのはちと用途が合わない気がする
    もう少し汎用的な Type として定義するなら検討の余地は有りなんだろうけど生かせたことがない 🤔
# 以下は多くなってくるなら app 以下に置いても良し
class ArticleStatus < ActiveModel::Type::String
  def cast_value(value)
    I18n.t("activerecord.attributes.article/status").invert.fetch(value).to_s
  end
end

# 以下を config/initializers/active_model_types.rb のファイルなどで定義する
ActiveModel::Type.register(:article_status, ArticleStatus)
app(dev)> ArticleForm.new(status: "公開").attributes
=> {"title"=>nil, "body"=>nil, "status"=>"publish", "custom_fields"=>nil}
app(dev)> ArticleForm.new(status: "非公開").attributes
=> {"title"=>nil, "body"=>nil, "status"=>"privated", "custom_fields"=>nil}
app(dev)> ArticleForm.new(status: "非公").attributes
config/initializers/active_model_types.rb:3:in `fetch': key not found: "非公" (KeyError)
Did you mean?  "非公開"
        from config/initializers/active_model_types.rb:3:in `cast_value'
from (app):3:in `<top (required)>'
app(dev)> ArticleForm.new(status: "").attributes
config/initializers/active_model_types.rb:3:in `fetch': key not found: "" (KeyError)
        from config/initializers/active_model_types.rb:3:in `cast_value'
from (app):4:in `<top (required)>'

enum 値の判定ロジック

先ほどの to_model の流れを汲むと以下のように ActiveRecord::Enum が動的に生成するメソッドを動的に参照することはできたりする。

class ArticleForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :title, :string
  attribute :body, :string
  attribute :status_i18n, :string
  attribute :custom_fields, :string

  Article.statuses.keys.each do |key|
    delegate "#{key}_status?".to_sym, to: :to_model
  end

  def to_model
    to_model_attributes = attributes.except("status_i18n").merge(status: status)
    Article.new(to_model_attributes)
  end

  private

  def status
    I18n.t("activerecord.attributes.article/status").invert.fetch(status_i18n)
  end
end

これでフォームオブジェクト側でも同じ判定用のAPIが利用できる。ActiveRecord 側の enum 定義が増えた時も対応不要な実装になると言うのは嬉しいところ

app(dev)> ArticleForm.new(status_i18n: "下書き").draft_status?
=> true
app(dev)> ArticleForm.new(status_i18n: "下書き").publish_status?
=> false
app(dev)> ArticleForm.new(status_i18n: "公開").publish_status?
=> true

ActiveModel 上にしか存在しない attribute ではどうするか

元々この話が発端であったものの、ここまで色々整理してみると普段使いしてる enum に寄せるためにどう言う実装が求められているかが大分見えて来た。
あとは引き算になるイメージで ActiveRecord::Enum に頼らずに自分で実装するしかないと言うだけのオチでしかないので大した書くこともなくなってしまった。

基本実装

ちょっと実装の都合上、フォームオブジェクトともまたちょっと違った性質の例にします。
こんな感じで jsonb 型の attribute を表現する用のインスタンスを作成するような使い勝手を想定してみます。 validates 定義も本当は良い感じに大元のモデルに引き継ぐとかをやる想定で。

class Article < ApplicationRecord
  enum :status, { draft: 0, publish: 1, privated: 10 }, suffix: true

  validates :title, presence: true

  delegate :author, :category, :tags, to: :custom_fields_to_instance

  class CustomFields
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :author, :string
    attribute :category, :string
    attr_accessor :tags
  end

  def custom_fields_to_instance
    CustomFields.new(custom_fields)
  end
end

例えば、こう言う使い勝手になる。

app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).attributes
=> {"id"=>nil, "title"=>nil, "body"=>"", "status"=>"draft", "custom_fields"=>{"author"=>"ebi", "category"=>"rails-related", "tags"=>["enum", "ActiveModel::Attributes"]}, "created_at"=>nil, "updated_at"=>nil}
app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).author
=> "ebi"
app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).category
=> "rails-related"
app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).tags
=> ["enum", "ActiveModel::Attributes"]

attribute のセットに関して

正直 ActiveRecord::Enum が直接絡まないとなると integer を DB に保存する生の値にする選択肢は減ってくる気がする。
単純なのは enum 値の定義を定数定義することだが、これもi18nの辞書定義があるのだったらそれを参照する感じにもできたりはする。
でも流石に少し責務を逸脱し過ぎていて強引過ぎる気はするので悩みどころ。

  • シンボルの文字列値を使いつつ、バリデーション定義などもする場合。
class CustomFields
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :author, :string
  attribute :category, :string
  attr_accessor :tags

  validates :category, presence: true, inclusion: { in: I18n.t("activerecord.attributes.article/category").keys.map(&:to_s) }
end
app(dev)> Article.new(custom_fields: { author: "ebi", tags: ["enum", "ActiveModel::Attributes"] }).custom_fields_to_instance.tap { |cf| cf.valid? }.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=category, type=blank, options={}>, #<ActiveModel::Error attribute=category, type=inclusion, options={:value=>nil}>]>
app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails", tags: ["enum", "ActiveModel::Attributes"] }).custom_fields_to_instance.tap { |cf| cf.valid? }.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=category, type=inclusion, options={:value=>"rails"}>]>
app(dev)> Article.new(custom_fields: { author: "ebi", category: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).custom_fields_to_instance.tap { |cf| cf.valid? }.errors
=> #<ActiveModel::Errors []>
  • 日本語でセットしたい場合
class CustomFields
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :author, :string
  attribute :category_i18n, :string
  attr_accessor :tags

  def category
    I18n.t("activerecord.attributes.article/category").invert.fetch(category_i18n)
  end
end
app(dev)> Article.new(custom_fields: { author: "ebi", category_i18n: "rails-related", tags: ["enum", "ActiveModel::Attributes"] }).category
app/models/article.rb:20:in `fetch': key not found: "rails-related" (KeyError)
        from app/models/article.rb:20:in `category'
from app/models/article.rb:6:in `category'
        from (app):103:in `<top (required)>'
app(dev)> Article.new(custom_fields: { author: "ebi", category_i18n: "Rails関連", tags: ["enum", "ActiveModel::Attributes"] }).category
=> :"rails-related"

おわり

飽きてきたのでこのくらいにしておきます。
次回があれば、もう少し話題を絞って enum の実装パターンや使い道を深掘りする記事を本当は書きたいです。


BPSアドベントカレンダー2024


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。