まずはenumの話
enumって?
列挙体がRails+ActiveRecordで利用できるよ(4.1.8以降)
元javaエンジニア的にはちょっと嬉しい
その他のライブラリ
標準ではないライブラリたち。今回は扱わない。
https://github.com/AgilionApps/classy_enum
クラスベースなもの
https://github.com/brainspec/enumerize
i18nに対応しているもの
どう使うの?
下準備
class User < ActiveRecord::Base
enum role: { admin: 10, guest: 20 }
end
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.integer :role
end
end
end
値の取り扱い
いろいろなアクセス方法が用意されているので、場面に合わせて使い分けよう。
set系
値を設定する方法
基本的にはシンボルまたは文字列で設定する。
user.role = :guest # => "guest"
user.role = 'guest' # => "guest"
添字でも設定可能だけど、あまり使いどころはなさそう?
user.role = 20 # => 20
値を変更する方法
このパターンだと即時Updateが発生する。
user.guest!
# (0.5ms) BEGIN
# SQL (0.5ms) UPDATE `users` SET `role` = 20 WHERE `users`.`id` = 1
# (12.2ms) COMMIT
#=> true
get系
設定値を文字列で取得
user.role # => "guest"
指定の要素かどうかを判定する
user.guest? # => true
user.admin? # => false
全種類取得
select_box作る時などに使える。
User.roles # => {"admin"=>10, "guest"=>20}
DBアクセス
保存される値を取り出して、それで検索
integerで保存されていれば範囲検索もできる
User.where(role: User.roles[:admin])
# [#<User:0x00000000000000
# id: 1,
# role: 10>]
User.where(role: User.roles[:admin]..User.roles[:guest])
具体例 : form + select_box
view
= form_for @user do |f|
= f.select :roles, User.roles.keys.to_a
view + i18n
日本語化も標準にある機能だけでできるよ。
= form_for @user do |f|
- select_options = User.roles.keys.map do |role|
- [t("roles.#{role}"), role]
= f.select :roles, select_options
ja:
roles:
admin: 管理者
guest: ゲスト
controller
特に処理しなくてassignできる!
def update
# params = { "user" => { "role" => "admin" } ... }
@user.update_attributes(user_params)
end
Tips・注意点
デフォルト値はあったほうが運用にやさしいので設定しよう。
class User < ActiveRecord::Base
enum role: {
no_role: 0,
admin: 10,
guest: 20,
}
end
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.integer :role, null: false, default: 0
end
end
end
DBに保存する値
文字列やbooleanをDBに保存できる
(当然migrationもそれに合わせよう)
class User < ActiveRecord::Base
enum role: {
admin: :admin,
guest: :guest,
}
end
class User < ActiveRecord::Base
enum role: {
admin: true,
guest: false,
}
end
Arrayで設定できる(推奨しない)
class User < ActiveRecord::Base
enum role: [:admin, :guest]
end
Arrayの添字がDBに保存されるよ
なので、要素を追加する際、末尾に追加しないとずれるので止めたほうが無難
要素の名前はかぶせない
怒られる。
class User < ActiveRecord::Base
enum role: { admin: 10, guest: 20 }
enum subrole: { admin: 10, guest: 20 }
end
User.new
# ArgumentError: You tried to define an enum named "subrole" on the model "User", but this will generate a instance method "admin?", which is already defined by another enum.
# from (略)/vendor/bundle/ruby/2.2.0/gems/activerecord-4.2.7.1/lib/active_record/enum.rb:187:in `detect_enum_conflict!'
名前がかぶらないように
prefixをつけよう
class User < ActiveRecord::Base
enum role: { admin: 10, guest: 20 }
enum subrole: { sub_admin: 10, sub_guest: 20 }
end
名前がかぶらないように(Rails5)
prefix, suffixをつける機能がRails5以降にはあるらしい
class User < ActiveRecord::Base
enum role: { admin: 10, guest: 20 }
enum subrole: { admin: 10, guest: 20 }, _prefix: :sub
end
STIと組み合わせた話
Model
クラスごとに違う値を取るように制限したい!
class User < ActiveRecord::Base
enum role: { no_role: 0 }
end
class User::Manager < User
enum role: { ceo: 110, cto: 120, cfo: 130 }
end
class User::Worker < User
enum role: { engineer: 210, hr: 220, account: 230 }
end
## View
こんな感じで1個だけ選択して、User#type
とUser#role
を送信するフォームを作ったという前提
Contorller
ここで問題が!
def new
User.new(user_params)
end
def update
@user.update_attributes(user_params)
end
# params = { "user" => { "type" => "User::Worker", "role" => "engineer" } ... }
new
の場合は特に問題なく成功。
update
の場合、かつtypeの変更がある場合はエラーになる!
user.type
# => User::Worker
user.update_attributes({ type: "User::Manager", role: :cto })
# => ArgumentError: 'cto' is not a valid role
回避策(失敗)
先にSTIのクラスを変化させればいけるんじゃね?(ダメでした)
user.becomes User::Manager
user.role = :cto
# => ArgumentError: 'cto' is not a valid role
enumの仕組みどうなってんの?
how : メタプログラミングでインスタンスメソッドを作成している
when: Modelに書いたenum :foo { :bar }
が評価された時
module ActiveRecord
module Enum
def enum(definitions)
klass = self
definitions.each do |name, values|
# statuses = { }
enum_values = ActiveSupport::HashWithIndifferentAccess.new
name = name.to_sym
# def self.statuses statuses end
detect_enum_conflict!(name, name.to_s.pluralize, true)
klass.singleton_class.send(:define_method, name.to_s.pluralize) { enum_values }
_enum_methods_module.module_eval do
# def status=(value) self[:status] = statuses[value] end
klass.send(:detect_enum_conflict!, name, "#{name}=")
define_method("#{name}=") { |value|
if enum_values.has_key?(value) || value.blank?
self[name] = enum_values[value]
else
raise ArgumentError, "'#{value}' is not a valid #{name}"
end
}
becomesすると何が起こるの?
Modelのtypeの値を変更しているだけ
= becomes
後もインスタンスはUser::Worker
のまま!
user.becomes User::Manager`
# => #<User::Worker:0x00000000000000 id: 1, type: "User::Manager", role: 10>]
回避策1
一旦保存してreloadしたら行けるはず!(できた)
user.becomes User::Manager
user.save
user.reload
user.role = :cto # => true
回避策2
becomes!
メソッドもあるよ
https://github.com/rails/rails/blob/master/activerecord/lib/active_record/persistence.rb
user.becomes! User::Manager
user.role = :cto # => true
回避策2:説明
-
becomes
は同じメモリ参照するよ -
becomes!
は新しいインスタンス作るよ
(この辺の用語の正確さには自信がないです…)
# Returns an instance of the specified +klass+ with the attributes of the
# current record. This is mostly useful in relation to single-table
# inheritance structures where you want a subclass to appear as the
# superclass. This can be used along with record identification in
# Action Pack to allow, say, <tt>Client < Company</tt> to do something
# like render <tt>partial: @client.becomes(Company)</tt> to render that
# instance using the companies/company partial instead of clients/client.
#
# Note: The new instance will share a link to the same attributes as the original class.
# Therefore the sti column value will still be the same.
# Any change to the attributes on either instance will affect both instances.
# If you want to change the sti column as well, use #becomes! instead.
def becomes(klass)
became = klass.new
became.instance_variable_set("@attributes", @attributes)
became.instance_variable_set("@mutation_tracker", @mutation_tracker) if defined?(@mutation_tracker)
became.instance_variable_set("@changed_attributes", attributes_changed_by_setter)
became.instance_variable_set("@new_record", new_record?)
became.instance_variable_set("@destroyed", destroyed?)
became.errors.copy!(errors)
became
end
# Wrapper around #becomes that also changes the instance's sti column value.
# This is especially useful if you want to persist the changed class in your
# database.
#
# Note: The old instance's sti column value will be changed too, as both objects
# share the same set of attributes.
def becomes!(klass)
became = becomes(klass)
sti_type = nil
if !klass.descends_from_active_record?
sti_type = klass.sti_name
end
became.public_send("#{klass.inheritance_column}=", sti_type)
became
end
回避策3
バリデーションで頑張った方がいいかも
- inclusionの指定は文字列なのに注意
class User < ActiveRecord::Base
enum role: {
no_role: 0,
ceo: 110, cto: 120, cfo: 130,
engineer: 210, hr: 220, account: 230,
}
validate :role,
inclusion: { in: %w(ceo cto cfo) },
if: -> { manager? }
validate :role,
inclusion: { in: %w(engineer hr account) },
if: -> { worker? }
def manager?
type == 'User::Manager'
end
def worker?
type == 'User::Worker'
end
end
class User::Manager < User
end
class User::Manager < User
end
同じ轍を踏んでみた
Validationも同じように再評価が必要っぽい(深追いしてない)
class User < ActiveRecord::Base
enum role: {
no_role: 0,
ceo: 110, cto: 120, cfo: 130,
engineer: 210, hr: 220, account: 230,
}
end
class User::Manager < User
validate :role,
inclusion: { in: %w(ceo cto cfo) }
end
class User::Manager < User
validate :role,
inclusion: { in: %w(engineer hr account) }
end
まとめ
- enumは便利
- enumはモジュールメソッド
- STIを使うときにはクラスとインスタンスの関係に気をつけよう
参考
http://apidock.com/rails/ActiveRecord/Enum
http://edgeapi.rubyonrails.org/classes/ActiveRecord/Enum.html
http://qiita.com/kakipo/items/092cc523849ea324d268
http://itakeshi.hatenablog.com/entry/2014/04/06/131759