先日業務中に遭遇したちょっとした疑問から 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 の実装パターンや使い道を深掘りする記事を本当は書きたいです。