search
LoginSignup
17
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

posted at

updated at

RailsのenumとSTIを組み合わせてみたらハマった件

RailsのenumとSTIを組み合わせてみたらハマった件

by na9amura
1 / 41

まずはenumの話

enumって?

列挙体がRails+ActiveRecordで利用できるよ(4.1.8以降)
元javaエンジニア的にはちょっと嬉しい


その他のライブラリ

標準ではないライブラリたち。今回は扱わない。

https://github.com/AgilionApps/classy_enum
クラスベースなもの

https://github.com/brainspec/enumerize
i18nに対応しているもの


どう使うの?


下準備

user.rb
class User < ActiveRecord::Base
  enum role: { admin: 10, guest: 20 }
end
migration
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.html.haml
= form_for @user do |f|
  = f.select :roles, User.roles.keys.to_a

こんな感じに表示される
select.png


view + i18n

日本語化も標準にある機能だけでできるよ。

_form.html.haml
= form_for @user do |f|
  - select_options = User.roles.keys.map do |role|
    - [t("roles.#{role}"), role]
  = f.select :roles, select_options
ja.yml
ja:
  roles:
    admin: 管理者
    guest: ゲスト

select_i18n.png


controller

特に処理しなくてassignできる!

users_controller.rb
def update
  # params = { "user" => { "role" => "admin" } ... }
  @user.update_attributes(user_params)
end

Tips・注意点


デフォルト値はあったほうが運用にやさしいので設定しよう。

user.rb
class User < ActiveRecord::Base
  enum role: {
    no_role: 0,
    admin:  10,
    guest:  20,
  }
end
migration
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もそれに合わせよう)

user.rb
class User < ActiveRecord::Base
  enum role: {
    admin: :admin,
    guest: :guest,
  }
end
user.rb
class User < ActiveRecord::Base
  enum role: {
    admin: true,
    guest: false,
  }
end

Arrayで設定できる(推奨しない)

user.rb
class User < ActiveRecord::Base
  enum role: [:admin, :guest]
end

Arrayの添字がDBに保存されるよ
なので、要素を追加する際、末尾に追加しないとずれるので止めたほうが無難


要素の名前はかぶせない

怒られる。

user.rb
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をつけよう

user.rb
class User < ActiveRecord::Base
  enum role:    { admin: 10, guest: 20 }
  enum subrole: { sub_admin: 10, sub_guest: 20 }
end

名前がかぶらないように(Rails5)

prefix, suffixをつける機能がRails5以降にはあるらしい

user.rb
class User < ActiveRecord::Base
  enum role:    { admin: 10, guest: 20 }
  enum subrole: { admin: 10, guest: 20 }, _prefix: :sub
end

STIと組み合わせた話


Model

クラスごとに違う値を取るように制限したい!

user.rb
class User < ActiveRecord::Base
  enum role: { no_role: 0 }
end
manager.rb
class User::Manager < User
  enum role: { ceo: 110, cto: 120, cfo: 130 }
end
worker.rb
class User::Worker < User
  enum role: { engineer: 210, hr: 220, account: 230 }
end

 View

こんな感じで1個だけ選択して、User#typeUser#roleを送信するフォームを作ったという前提

select_work_role.png


Contorller

ここで問題が!

users_controller.rb
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 }が評価された時

active_record/enum.rb(抜粋)
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の指定は文字列なのに注意

user.rb
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
manager.rb
class User::Manager < User
end
worker.rb
class User::Manager < User
end

同じ轍を踏んでみた

Validationも同じように再評価が必要っぽい(深追いしてない)

user.rb
class User < ActiveRecord::Base
  enum role: {
    no_role: 0,
    ceo: 110, cto: 120, cfo: 130,
    engineer: 210, hr: 220, account: 230,
  }
end
manager.rb
class User::Manager < User
  validate :role,
    inclusion: { in: %w(ceo cto cfo) }
end
worker.rb
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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
17
Help us understand the problem. What are the problem?