Skip to content
Browse files

Added ActiveRecord::Base#enum for declaring enum attributes where the…

… values map to integers in the database, but can be queried by name
  • Loading branch information...
1 parent deaf285 commit db41eb8a6ea88b854bf5cd11070ea4245e1639c5 @dhh dhh committed
View
25 activerecord/CHANGELOG.md
@@ -1,3 +1,28 @@
+* Added ActiveRecord::Base#enum for declaring enum attributes where the values map to integers in the database, but can be queried by name.
+
+ Example:
+ class Conversation < ActiveRecord::Base
+ enum status: %i( active archived )
+ end
+
+ Conversation::STATUS # => { active: 0, archived: 1 }
+
+ # conversation.update! status: 0
+ conversation.active!
+ conversation.active? # => true
+ conversation.status # => :active
+
+ # conversation.update! status: 1
+ conversation.archived!
+ conversation.archived? # => true
+ conversation.status # => :archived
+
+ # conversation.update! status: 1
+ conversation.status = :archived
+
+ *DHH*
+
+
* ActiveRecord::Base#attribute_for_inspect now truncates long arrays (more than 10 elements)
*Jan Bernacki*
View
1 activerecord/lib/active_record.rb
@@ -37,6 +37,7 @@ module ActiveRecord
autoload :ConnectionHandling
autoload :CounterCache
autoload :DynamicMatchers
+ autoload :Enum
autoload :Explain
autoload :Inheritance
autoload :Integration
View
1 activerecord/lib/active_record/base.rb
@@ -291,6 +291,7 @@ class Base
extend Translation
extend DynamicMatchers
extend Explain
+ extend Enum
extend Delegation::DelegateCache
include Persistence
View
60 activerecord/lib/active_record/enum.rb
@@ -0,0 +1,60 @@
+module ActiveRecord
+ # Declare an enum attribute where the values map to integers in the database, but can be queried by name. Example:
+ #
+ # class Conversation < ActiveRecord::Base
+ # enum status: %i( active archived )
+ # end
+ #
+ # Conversation::STATUS # => { active: 0, archived: 1 }
+ #
+ # # conversation.update! status: 0
+ # conversation.active!
+ # conversation.active? # => true
+ # conversation.status # => :active
+ #
+ # # conversation.update! status: 1
+ # conversation.archived!
+ # conversation.archived? # => true
+ # conversation.status # => :archived
+ #
+ # # conversation.update! status: 1
+ # conversation.status = :archived
+ #
+ # You can set the default value from the database declaration, like:
+ #
+ # create_table :conversation do
+ # t.column :status, :integer, default: 0
+ # end
+ #
+ # Good practice is to let the first declared status be the default.
+ module Enum
+ def enum(definitions)
+ definitions.each do |name, values|
+ const_name = name.to_s.upcase
+
+ # DIRECTION = { }
+ const_set const_name, {}
+
+ # def direction=(value) self[:direction] = DIRECTION[value] end
+ class_eval "def #{name}=(value) self[:#{name}] = #{const_name}[value] end"
@pixeltrix Ruby on Rails member

@tenderlove didn't we decide that using define_method was better than class_eval for dynamically defining methods - I remember you doing some research into this.

@tenderlove Ruby on Rails member
@dhh Ruby on Rails member
dhh added a note

I think it just appeared clearer to me in the code. Are you saying that class_eval is a performance issue? That seems surprising to me given that this is only called once per listing and at compile time.

@ka8725
ka8725 added a note

define_method is better because you will have properly worked respond_to?

define_method is better because you will have properly worked respond_to?

@ka8725 what do you mean?

@ck3g
ck3g added a note

@ka8725 I guess there is no difference for respond_to?.

2.0.0p247 :001 > class MyClass
2.0.0p247 :002?>   define_method :my_define_method do
2.0.0p247 :003 >       'my_define_method'
2.0.0p247 :004?>     end
2.0.0p247 :005?>   
2.0.0p247 :006 >     class_eval <<-EVAL
2.0.0p247 :007">       def my_class_eval
2.0.0p247 :008">         'my_class_eval'
2.0.0p247 :009">       end
2.0.0p247 :010">     EVAL
2.0.0p247 :011?>   end
 => nil 
2.0.0p247 :012 > MyClass.new.respond_to? :my_define_method
 => true 
2.0.0p247 :013 > MyClass.new.respond_to? :my_class_eval
 => true 

Did I understood you correctly?

@ka8725
ka8725 added a note

Yes, you are right. It's my fault

@pixeltrix Ruby on Rails member

@dhh what @tenderlove is saying is that you should only use class_eval if you need maximum performance for a simple method. If the work done in the method is anything significant then the call performance gains are masked by the time taken to do the work. More information is in his blog post: http://tenderlovemaking.com/2013/03/03/dynamic_method_definitions.html

@dhh Ruby on Rails member
dhh added a note
@ck3g
ck3g added a note

@dhh

Ah. I'd be ok with a patch for define_method. I prefer the look of class_eval but I'm not attached to that.

#12754

@dhh Ruby on Rails member
dhh added a note

Gotta love open source! Thanks @ck3g!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
+ # def direction() DIRECTION.key self[:direction] end
+ class_eval "def #{name}() #{const_name}.key self[:#{name}] end"
+
+ values.each_with_index do |value, i|
+ # DIRECTION[:incoming] = 0
+ const_get(const_name)[value] = i
+
+ # scope :incoming, -> { where direction: 0 }
+ scope value, -> { where name => i }
+
+ # def incoming?() direction == 0 end
+ class_eval "def #{value}?() self[:#{name}] == #{i} end"
+
+ # def incoming! update! direction: :incoming end
+ class_eval "def #{value}!() update! #{name}: :#{value} end"
+ end
+ end
+ end
+ end
+end
View
36 activerecord/test/cases/enum_test.rb
@@ -0,0 +1,36 @@
+require 'cases/helper'
+require 'models/book'
+
+class StoreTest < ActiveRecord::TestCase
+ fixtures :books
+
+ setup do
+ @book = Book.create! name: 'REMOTE'
@drbonzo
drbonzo added a note

nice line :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ end
+
+ test "query state by predicate" do
+ assert @book.proposed?
+ assert_not @book.written?
+ assert_not @book.published?
+ end
+
+ test "query state with symbol" do
+ assert_equal :proposed, @book.status
+ end
+
+ test "update by declaration" do
+ @book.written!
+ assert @book.written?
+ end
+
+ test "update by setter" do
+ @book.update! status: :written
+ assert @book.written?
+ end
+
+ test "constant" do
+ assert_equal 0, Book::STATUS[:proposed]
+ assert_equal 1, Book::STATUS[:written]
+ assert_equal 2, Book::STATUS[:published]
+ end
+end
View
6 activerecord/test/models/book.rb
@@ -2,8 +2,10 @@ class Book < ActiveRecord::Base
has_many :authors
has_many :citations, :foreign_key => 'book1_id'
@mcspring
mcspring added a note

Refactor this to new ruby hash syntax?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
- has_many :references, -> { distinct }, :through => :citations, :source => :reference_of
+ has_many :references, -> { distinct }, through: :citations, source: :reference_of
has_many :subscriptions
- has_many :subscribers, :through => :subscriptions
+ has_many :subscribers, through: :subscriptions
+
+ enum status: %i( proposed written published )
end
View
1 activerecord/test/schema/schema.rb
@@ -94,6 +94,7 @@ def create_table(*args, &block)
create_table :books, :force => true do |t|
t.integer :author_id
t.column :name, :string
+ t.column :status, :integer, default: 0
end
create_table :booleans, :force => true do |t|

30 comments on commit db41eb8

@laurocaetano

awesome :+1:

@lbramos

awesome x2 (Y)

@byroot
Ruby on Rails member

I though it was a support for mysql and postgres enum types. :sad:

@gotar

thx, (+1)

@mlangenberg

Be careful when adding or removing values later on. It might be better to do an explicit mapping to an integer, instead of relying on the order of elements in the status array.

Given the example enum status: %i( proposed written published ) and you eventually decide you don't need the 'written' state at all. Making this change enum status: %i( proposed published ), will be very confusing, since written books are now suddenly published. You could argue for a migration, but I would rather stick with mapping '2' to 'published'.

At least when using MySQL enum type, it can migrate data for you (albeit expensive). With the mapping in Rails, why not support a tuples with values and integers?

enum status: [[:proposed, 0], [:written, 1], [:published, 2]]

@dhh
Ruby on Rails member

@mlangenberg, I'd be happy to see a patch that also supports explicit mapping, but let's go with this format:

enum status: { proposed: 0, written: 1, published: 2 }

But it would be in addition to what we have now.

@egilburg

Personally a more common pattern I've seen is where the enum values are already stored in db under the matching column name. For example:

In seeds.rb:

Role.create!(name: 'admin')
Role.create!(name: 'user')

And what is needed API like:

Role.admin # => [<#Role>]
Role.first.admin? # => true or false, instead of the more clunky StringInquirer way of Role.first.name.admin?
Role.first.admin! # (sets 'name' to 'admin')

It would be cool if this was supported by:

class Role < ActiveRecord::Base
  enum name: i%( admin user )
end

Not sure if this will clash with current implementation though.

@dhh
Ruby on Rails member

It's pretty inefficient to store enums as text. You're going to repeat the same text over and over and over again. I'd consider that an anti pattern. People are better off doing a migration to ints if they want to use this.

@byroot
Ruby on Rails member

But integers remove readability / discoverability.

This is why MySQL, Postgres and Oracle (at least) offer enums that expose text but store integers. Support was added for postgres arrays and hstore, why not adding support for enums ?

@dhh
Ruby on Rails member
@byroot
Ruby on Rails member

I'll do.

@yury

@dhh here is PR for explicit mapping.

@rahult

awesome :thumbsup:

@koteus

It would be nice if this had a bitmask feature out of the box. For example to assign multiple roles to a user:

enum roles: %i(writer publisher editor admin), multiple: true

And usage:

user = User.create name: "David", roles: %i(publisher editor)
user.roles
# => [:publisher, :editor]
user.roles << :writer
user.roles
# => [:publisher, :editor, :writer]

But there's a gem for this already: https://github.com/joelmoss/bitmask_attributes

@joelmoss

I might be biased here, but not seeing why you wouldn't just use the bitmask_attributes gem.

@yonbergman

Hey, I have a pretty similar gem that adds enum functionality /yonbergman/enumify
and we found that having a not scope for each enum value is really helpful -

# scope :not_incoming, -> { where.not direction: 0 }
scope "not_#{value}", -> { where.not name => i }

Other than that awesome feature :)

@ka8725

@yonbergman, you forgot to mention about validations, form helpers, i18n, ability to change column type, multiplying and some more useful features.

@samqiu

@dhh Why are you so awesome!? :+1: :+1: :+1:

@HuffMoody

I think it would be helpful to prepend the enum name to the generated instance methods. This would help avoid collisions of two different enum fields on the same model having an identical enum value. Example below:

class Foo < ActiveRecord::Base
  enum :state, [:open, :closed]
  enum :other_state, [:something, :closed]
end

The generated methods #closed, #closed? and #closed! for other_state would collide. Generating methods like #state_closed, #state_closed? and #state_closed! (likewise with other_state) would solve the collision issue.

@dhh
Ruby on Rails member
@chancancode
Ruby on Rails member

We are adding the error, see #13389

@HuffMoody

@dhh make sense, probably not a likely scenario anyways I suppose.

@chancancode
Ruby on Rails member

By the way, if you ended up with a collision, you can always prefix them yourself, like enum :state, [:state_open, :state_closed], and you'll get exactly the same thing. Meanwhile, the common case gets the nice syntax. Best of both worlds(TM)

@kenn

I think raise on collision is a sane default for simple use cases, but when your requirements need more, here's another gem: https://github.com/kenn/enum_accessor it does what @HuffMoody mentioned above.

I'm looking forward to seeing validation / i18n support and hopefully I'll be able to ditch my own implementation someday. :)

@imanel

I tried to implement optional prefixing for those that need it in #13433 - I believe that it's much easier to use it like:

# enum :state, [:open, :state], nested: true
conversation.status = :open
conversation.status #=> :open
conversation.status_open?
Conversation.status_open

instead of currently proposed solution:

# enum :state, [:state_open, :state_closed]
conversation.status = :status_open
conversation.status #=> :status_open
conversation.status_open?
Conversation.status_open

Current implementation might be little to verbose (I have this slight Java-like feeling ;)

@dhh
Ruby on Rails member
@imanel

Which, I believe, is very inconvenient and counterintuitive for some users. It's much more obvious to set status to "open" than to "status_open", needles to say that it's really unnecessary duplication. Best example is exposing such value via API, where you would need to convert from "status_open" to "open" during both read and write (I can't imagine API with "status" set to "status_open", so conversion would be a must). Prefixing is also optional, so default behavior is not changed and it should not interrupt how your proposal works.

@kenn

IMO, enum :state, [ :state_value ] should not be recommended as a workaround, I know first hand how ugly it would become in the source, that's the main reason that I created enum_accessor when I found at least 7 enum gems at the time but none did prefixing.

user.gender = :gender_man
user.save

user.gender
 => :gender_man

This is just so uncool.

Let's just raise on a collision and if multiple enums are necessary, say use gems like enum_accessor.

@imanel

I agree we should raise on a collision, but I would also be glad to see optional namespacing in Rails core so there would be no need to duplicate it using third party gems (especially that having it and not having it is 1 line of code)

Please sign in to comment.
Something went wrong with that request. Please try again.