Power Enum
https://github.com/albertosaurus/power_enum_2
Enumerations for Rails Done Right.
Versions
- PowerEnum 4.0.X (this version) supports Rails 6.X, and Rails 7.0 (Experimental)
- PowerEnum 3.X supports Rails 4.2, Rails 5.X and Rails 6.0
- PowerEnum 2.X supports Rails 4.X and Rails 5.0
- PowerEnum 1.X supports Rails 3.1/3.2, available here: https://github.com/albertosaurus/power_enum
What is this?:
Power Enum allows you to treat instances of your ActiveRecord models as though they were an enumeration of values. It allows you to cleanly solve many of the problems that the traditional Rails alternatives handle poorly if at all. It is particularly suitable for scenarios where your Rails application is not the only user of the database, such as when it's used for analytics or reporting.
Power Enum is a fork of the Rails 3 modernization made by the fine folks at Protocool https://github.com/protocool/enumerations_mixin to the original plugin by Trevor Squires. While many of the core ideas remain, it has been reworked and a full test suite written to facilitate further development.
At it's most basic level, it allows you to say things along the lines of:
# Create a provisional booking
booking = Booking.new( status: BookingStatus[:provisional] )
# This also works
booking = Booking.new( status: :provisional )
# Set the booking status to 'confirmed'
booking.status = :confirmed
booking = Booking.create( status: :rejected )
# And now...
booking.status == BookingStatus[:rejected] # evaluates to true
booking.status === :rejected # also evaluates to true
booking.status === [:rejected, :confirmed, :provisional] # and so does this
booking.status === [%i[rejected confirmed provisional]] # and this
Booking.where( :status_id => BookingStatus[:provisional] )
BookingStatus.all.collect { |status|, [status.name, status.id] }
# built in scopes make life easier
Booking.with_status( :provisional, :confirmed )
See "How to use it" below for more information.
Requirements
PowerEnum 4.0.X
- Ruby 2.7 or later (JRuby should work but isn't extensively tested).
- Rails 6.0, 6.1, 6.2, 7.0
PowerEnum 3.X
- Ruby 2.1 or later (JRuby should work but isn't extensively tested).
- Rails 4.2, 5.0, 5.1, 5.2, 6.0
PowerEnum 2.X
- Ruby 1.9.3, 2.0, JRuby 1.7+ (Ruby 1.9.3 or 2.0 required for development)
- Rails 4.0, 4.1, 4.2, 5.0
Installation
Using Bundler
Add the gem to your Gemfile
gem 'power_enum'
then run
bundle install
Manual Installation
gem install power_enum
Gem Contents
This package adds:
- Two mixins and a helper to ActiveRecord
- Methods to migrations to simplify the creation of backing tables
- Two generators to streamline working with enums
- Custom RSpec matchers to streamline the testing of enums and enumerated attributes
acts_as_enumerated
provides capabilities to treat your model and its records as an enumeration.
At a minimum, the database table for an acts_as_enumerated must contain an 'id' column and a column
to hold the value of the enum ('name' by default). It is strongly recommended that there be
a NOT NULL constraint on the 'name' column. All instances for the acts_as_enumerated
model
are cached in memory. If the table has an 'active' column, the value of that attribute
will be used to determine which enum instances are active.
Otherwise, all values are considered active.
has_enumerated
adds methods to your ActiveRecord model for setting and retrieving enumerated values using an
associated acts_as_enumerated model.
There is also an ActiveRecord::VirtualEnumerations
helper module to create 'virtual' acts_as_enumerated models
which helps to avoid cluttering up your models directory with acts_as_enumerated classes.
How to use it
In the following example, we'll look at a Booking that can have several types of statuses, encapsulated by BookingStatus enums.
generator
Invoke the generator to create a basic enum:
rails generate enum booking_status
You should see output similar to this:
create app/models/booking_status.rb
create db/migrate/20110926012928_create_enum_booking_status.rb
invoke test_unit
create test/unit/booking_status_test.rb
That's all you need to get started. In many cases, no further work on the enum is necessary. You can run rails generate enum --help
to see a description of the generator options. Notice, that while a unit test is generated by default, a fixture isn't. That is because
fixtures are not an ideal way to test acts_as_enumerated models. I generally prefer having a hook to seed the database from seeds.rb
from a pre-test Rake task.
migration
When you open your migration file, it will look something like this:
class CreateEnumBookingStatus < ActiveRecord::Migration
def change
create_enum :booking_status
end
end
You can now customize it.
create_enum :booking_status, :name_limit => 50
# The above is equivalent to saying
# create_table :booking_statuses do |t|
# t.string :name, :limit => 50, :null => false
# end
WARNING - This conflicts with PostgreSQL enum support in Rails 7+ and will be renamed in future versions.
Now, when you create your Booking model, your migration should create a reference column for status id's and a foreign key relationship to the booking_statuses table.
create_table :bookings do |t|
t.integer :status_id
t.timestamps
end
# It's highly recommended to add a foreign key constraint here.
# Ideally, you would use a gem of some sort to handle this for Rails < 6.
# I have been using PgPower https://rubygems.org/gems/pg_power with much
# success. It's fork, PgSaurus https://rubygems.org/gems/pg_saurus should
# work just as well.
execute "ALTER TABLE bookings ADD 'bookings_bookings_status_id_fk'"\
" FOREIGN KEY (status_id) REFERENCES booking_statuses (id);"
It's easier to use the references
method if you intend to stick to the default naming convention for reference columns.
create_table :bookings do |t|
t.references :booking_status # Same as t.integer booking_status_id
t.timestamps
end
There are two methods added to Rails migrations:
create_enum(enum_name, options = {}, &block)
WARNING - This conflicts with PostgreSQL enum support in Rails 7+ and will be renamed in future versions.
Creates a new enum table. enum_name
will be automatically pluralized. The following options are supported:
- [:name_column] Specify the column name for name of the enum. By default it's :name. This can be a String or a Symbol
- [:description] Set this to
true
to have a 'description' column generated. - [:name_limit] Set this define the limit of the name column.
- [:desc_limit] Set this to define the limit of the description column
- [:active] Set this to
true
to have a boolean 'active' column generated. The 'active' column will have the options of NOT NULL and DEFAULT TRUE. - [:timestamps] Set this to
true
to have the timestamp columns (created_at and updated_at) generated - [:table_options] Allows you to set a hash which will be passed directly to
create_table
. - [:schema] Allows you to create the enum table in a different schema (Version 2.6.0).
You can also pass in a block that takes a table object as an argument, like create_table
.
Example:
create_enum :booking_status
is the equivalent of
create_table :booking_statuses do |t|
t.string :name, :null => false
end
add_index :booking_statuses, [:name], :unique => true
In a more complex case:
create_enum :booking_status,
:name_column => :booking_name,
:name_limit => 50,
:description => true,
:desc_limit => 100,
:active => true,
:timestamps => true,
:table_options => {:primary_key => :foo}
is the equivalent of
create_table :booking_statuses, :primary_key => :foo do |t|
t.string :booking_name, :limit => 50, :null => false
t.string :description, :limit => 100
t.boolean :active, :null => false, :default => true
t.timestamps
end
add_index :booking_statuses, [:booking_name], :unique => true
You can also customize the creation process by using a block:
create_enum :booking_status do |t|
t.boolean :first_booking, :null => false
end
is the equivalent of
create_table :booking_statuses do |t|
t.string :name, :null => false
t.boolean :first_booking, :null => false
end
add_index :booking_statuses, [:name], :unique => true
Notice that a unique index is automatically created on the specified name column.
remove_enum(enum_name)
Drops the enum table. enum_name
will be automatically pluralized.
Example:
remove_enum :booking_status
is the equivalent of
drop_table :booking_statuses
acts_as_enumerated
class BookingStatus < ActiveRecord::Base
acts_as_enumerated :conditions => 'optional_sql_conditions',
:order => 'optional_sql_order_by',
:on_lookup_failure => :optional_class_method, # This also works: lambda{ |arg| some_custom_action }
:name_column => 'optional_name_column' # If required, may override the default name column
:alias_name => false, # If set to false and have name_column set, will not
# alias :name to the name column attribute.
:freeze_members => true # Optional, default is true in prod.
# This also works: lambda { true }
end
With that, your BookingStatus class will have the following methods defined:
Class Methods
BookingStatus[arg]
performs a lookup for the BookingStatus instance for the given arg. The arg value can be a
'string' or a :symbol, in which case the lookup will be against the BookingStatus.name field. Alternatively arg can be
a Integer, in which case the lookup will be against the BookingStatus.id field. It returns the arg if arg is an
instance of the enum (in this case BookingStatus) as a convenience.
The :on_lookup_failure
option specifies the name of a class method to invoke when the []
method is unable to
locate a BookingStatus record for arg. The default is the built-in :enforce_none
which returns nil. There are also
built-ins for :enforce_strict
(raise and exception regardless of the type for arg), :enforce_strict_literals
(raises
an exception if the arg is a Integer or Symbol), :enforce_strict_ids
(raises and exception if the arg is a Integer) and
:enforce_strict_symbols
(raises an exception if the arg is a Symbol).
The purpose of the :on_lookup_failure
option is that a) under some circumstances a lookup failure is a Bad Thing and
action should be taken, therefore b) a fallback action should be easily configurable. You can
also set :on_lookup_failure
to a lambda that takes in a single argument (The arg that was passed to []
).
You can also pass in multiple arguments to []
. This returns a list of enums corresponding to the
passed in values. Duplicates are filtered out. For example BookingStatus[arg1, arg2, arg3]
would be equivalent to
[BookingStatus[arg1], BookingStatus[arg2], BookingStatus[arg3]]
.
contains?(arg)
BookingStatus.contains?(arg)
returns true if
the given Symbol, String or id has a member instance in the enumeration,
false
otherwise. Returns true
if the argument is an enum instance, returns false
if the argument is nil
or any
other value.
all
BookingStatus.all
returns an array of all BookingStatus records that match the :conditions
specified in
acts_as_enumerated
, in the order specified by :order
.
all_except(*items)
BookingStatus.all_except(arg1, arg2)
returns an array of all BookingStatus records with the given items filtered out.
active
BookingStatus.active
returns an array of all BookingStatus records that are marked active. See the active?
instance
method.
inactive
BookingStatus.inactive
returns an array of all BookingStatus records that are inactive. See the inactive?
instance
method.
names
BookingStatus.names
will return all the names of the defined enums as an array of symbols.
update_enumerations_model
The preferred mechanism to update an enumerations model in migrations and similar. Pass in a block to this method to to perform any updates.
Example:
BookingStatus.update_enumerations_model do
BookingStatus.create :name => 'Foo',
:description => 'Bar',
:active => false
end
Example 2:
BookingStatus.update_enumerations_model do |klass|
klass.create :name => 'Foo',
:description => 'Bar',
:active => false
end
acts_as_enumerated?
Returns true
for ActiveRecord models that act as enumerated, false
for others. So
BookingStatus.acts_as_enumerated?
would return true
, while Booking.acts_as_enumerated?
would return false
.
Instance Methods
Each enumeration model gets the following instance methods.
===(arg)
Behavior depends on the type of arg
.
- If
arg
isnil
, returnsfalse
. - If
arg
is an instance ofSymbol
,Integer
orString
, returns the result ofBookingStatus[:foo] == BookingStatus[arg]
. - If
arg
is anArray
, returnstrue
if any member of the array returnstrue
for===(arg)
,false
otherwise. - In all other cases, delegates to
===(arg)
of the superclass.
Examples:
BookingStatus[:foo] === :foo #Returns true
BookingStatus[:foo] === 'foo' #Returns true
BookingStatus[:foo] === :bar #Returns false
BookingStatus[:foo] === [:foo, :bar, :baz] #Returns true
BookingStatus[:foo] === nil #Returns false
You should note that defining an :on_lookup_failure
method that raises an exception will cause ===
to also raise an
exception for any lookup failure of BookingStatus[arg]
.
like?(arg)
Aliased to ===
in?(*list)
Returns true if any element in the list returns true for ===(arg)
, false otherwise.
Example:
BookingStatus[:foo].in? :foo, :bar, :baz #Returns true
to_s
Returns the string representation of the enum, i.e. the value in the :name_column
attribute of the enumeration model.
name
By default, aliased to the string representation of the :name_column
attribute. To avoid this, set the alias_name
option to false
.
name_sym
Returns the symbol representation of the name of the enum. BookingStatus[:foo].name_sym
returns :foo.
to_sym
Aliased to name_sym
.
active?
Returns true if the instance is active, false otherwise. If it has an attribute 'active',
returns the attribute cast to a boolean, otherwise returns true. This method is used by the active
class method to select active enums.
inactive?
Returns true if the instance is inactive, false otherwise. Default implementations returns !active?
This method is used by the inactive
class method to select inactive enums.
Notes
acts_as_enumerated
records are considered immutable. By default you cannot create/alter/destroy instances because they
are cached in memory. Because of Rails' process-based model it is not safe to allow updating acts_as_enumerated
records as the caches will get out of sync. Also, to_s
is overriden to return the name of the enum instance.
However, one instance where updating the models should be allowed is if you are using seeds.rb to seed initial values into the database.
Using the above example you would do the following:
BookingStatus.enumeration_model_updates_permitted = true
['pending', 'confirmed', 'canceled'].each do | status_name |
BookingStatus.create( :name => status_name )
end
Note that a :presence
and :uniqueness
validation is automatically defined on each model for the name column.
has_enumerated
First of all, note that you could specify the relationship to an acts_as_enumerated
class using the belongs_to
association. However, has_enumerated
is preferable because you aren't really associated to the enumerated value, you
are aggregating it. As such, the has_enumerated
macro behaves more like an aggregation than an association.
class Booking < ActiveRecord::Base
has_enumerated :status,
:class_name => 'BookingStatus',
:foreign_key => 'status_id',
:on_lookup_failure => :optional_instance_method,
:permit_empty_name => true, #Setting this to true disables automatic conversion of empty strings to nil. Default is false.
:default => :unconfirmed, #Default value of the attribute.
:create_scope => false #Setting this to false disables the automatic creation of the 'with_status' scope.
end
By default, the foreign key is interpreted to be the name of your has_enumerated field (in this case 'booking_status')
plus '_id'. Since we chose to make the column name 'status_id' for the sake of brevity, we must explicitly designate
it. Additionally, the default value for :class_name
is the camelized version of the name for your has_enumerated
field. :on_lookup_failure
is explained below. :permit_empty_name
is an optional flag to disable automatic
conversion of empty strings to nil. It is typically desirable to have booking.update_attributes(:status => '')
assign status_id to a nil rather than raise an Error, as you'll be often calling update_attributes
with form data, but
the choice is yours. Setting a :default
option will generate an after_initialize callback to set a default value on
the attribute unless a non-nil value has already been set.
With that, your Booking class will have the following methods defined:
status
Returns the BookingStatus with an id that matches the value in the Booking.status_id.
status=(arg)
Sets the value for Booking.status_id using the id of the BookingStatus instance passed as an argument. As a short-hand, you can also pass it the 'name' of a BookingStatus instance, either as a 'string' or :symbol, or pass in the id directly.
example:
mybooking.status = :confirmed
this is equivalent to:
mybooking.status = 'confirmed'
or:
mybooking.status = BookingStatus[:confirmed]
The :on_lookup_failure
option in has_enumerated is there because you may want to create an error handler for
situations where the argument passed to status=(arg)
is invalid. By default, an invalid value will cause an
ArgumentError to be raised.
Of course, this may not be optimal in your situation. In this case you can do one of three things:
-
You can set it to 'validation_error'. In this case, the invalid value will be cached and returned on subsequent lookups, but the model will fail validation.
-
Specify an instance method to be called in the case of a lookup failure. The method signature is as follows:
your_lookup_handler(operation, name, name_foreign_key, acts_enumerated_class_name, lookup_value)
The 'operation' arg will be either :read
or :write
. In the case of :read
you are expected to return something or
raise an exception, while in the case of a :write
you don't have to return anything.
Note that there's enough information in the method signature that you can specify one method to handle all lookup failures for all has_enumerated fields if you happen to have more than one defined in your model.
- Give it a lambda function. In that case, the lambda needs to accept the ActiveRecord model as its first argument, with the rest of the arguments being identical to the signature of the lookup handler instance method.
:on_lookup_failure => lambda{ |record, op, attr, fk, cl_name, value|
# handle lookup failure
}
NOTE: A nil
is always considered to be a valid value for status=(arg)
since it's assumed you're trying to null out
the foreign key. The :on_lookup_failure
will be bypassed.
with_enumerated_attribute scope
Unless the :create_scope
option is set to false
, a scope is automatically created that takes a list of enums as
arguments. This allows us to say things like:
Booking.with_status :confirmed, :received
Strings, symbols, ids, or enum instances are all valid arguments. For example, the following would be valid, though not recommended for obvious reasons.
Booking.with_status 1, 'confirmed', BookingStatus[:rejected]
As a convenience, it also aliases a pluralized version of the scope, i.e. :with_statuses
exclude_enumerated_attribute scope
By default, a scope for the inverse of with_enumerated_attribute
is created, unless the :create_scope
option is set to false
. As a result, this allows us to do things like
Booking.exclude_status :received
This will give us all the Bookings where the status is a value other than BookingStatus[:received]
.
NOTE: This will NOT pick up instances of Booking where status is nil.
A pluralized version of the scope is also created, so Booking.exclude_statuses :received, :confirmed
is valid.
ActiveRecord::Base Extensions
The following methods are added to ActiveRecord::Base as class methods.
has_enumerated?(attr)
Returns true if the given attr is an enumerated attributes, false otherwise. attr
can be a string or a symbol. This
is a class method.
enumerated_attributes
Returns an array of attributes which are enumerated.
ActiveRecord::VirtualEnumerations
In many instances, your acts_as_enumerated
classes will do nothing more than just act as enumerated. In that case,
you can use ActiveRecord::VirtualEnumerations to reduce that clutter.
Create a custom Rails initializer: Rails.root/config/initializers/virtual_enumerations.rb
To streamline this, a generator is provided:
rails generate virtual_enumerations_initializer
Configure as appropriate.
ActiveRecord::VirtualEnumerations.define do |config|
# Define the enum class
config.define 'ClassName',
:table_name => 'table',
:extends => 'SuperclassName',
:conditions => ['something = ?', "value"],
:order => 'column ASC',
:on_lookup_failure => :enforce_strict,
:name_column => 'name_column',
:alias_name => false {
# This gets evaluated within the class scope of the enum class.
def to_s
"#{id} - #{name}"
end
}
end
Only the 'ClassName' argument is required. :table_name
is used to define a custom table name while the :extends
option is used to set a custom superclass. Class names can be either camel-cased like ClassName or with
underscores, like class_name. Strings and symbols are both fine.
If you need to fine-tune the definition of the enum class, you can optionally pass in a block, which will be evaluated in the context of the enum class.
Example:
config.define :color, :on_lookup_failure => :enforce_strict, do
def to_argb(alpha)
case self.to_sym
when :white
[alpha, 255, 255, 255]
when :red
[alpha, 255, 0, 0]
when :blue
[alpha, 0, 0, 255]
when :yellow
[alpha, 255, 255, 0]
when :black
[alpha, 0, 0, 0]
end
end
end
As a convenience, if multiple enums share the same configuration, you can pass all of them to config.define.
config.define :booking_status, :connector_type, :color, :order => :name
STI is also supported:
config.define :base_enum, :name_column => ;foo
config.define :booking_status, :connector_type, :color, :extends => :base_enum
Testing
A pair of custom RSpec matchers are included to streamline testing of enums and enumerated attributes.
act_as_enumerated
This is used to test that a model acts as enumerated. Example:
describe BookingStatus do
it { should act_as_enumerated }
end
This also works:
describe BookingStatus do
it "should act as enumerated" do
BookingStatus.should act_as_enumerated
end
end
You can use the with_items
chained matcher to test that each enum is properly seeded:
describe BookingStatus do
it {
should act_as_enumerated.with_items(:confirmed, :received, :rejected)
}
end
You can also pass in hashes if you want to be thorough and test out all the attributes of each enum. If
you do this, you must pass in the :name
attribute in each hash
describe BookingStatus do
it {
should act_as_enumerated.with_items(
{ :name => 'confirmed', :description => "Processed and confirmed" },
{ :name => 'received', :description => "Pending confirmation" },
{ :name => 'rejected', :description => "Rejected due to internal rules" }
)
}
end
have_enumerated
This is used to test that a model has enumerated the given attribute:
describe Booking do
it { should have_enumerated(:status) }
end
This is also valid:
describe Booking do
it "Should have enumerated the status attribute" do
Booking.should have_enumerated(:status)
end
end
match_enum
Tests if an enum instance matches the given value, which may be a symbol, id, string, or enum instance:
describe Booking do
it "status should be 'received' for a new booking" do
Booking.new.status.should match_enum(:received)
end
end
Of course Booking.new.status.should === :received
still works, but is liable to produce false positives.
How to run tests
Prepare the test database
Automatically (preferred)
Execute the test setup script:
script/test_setup.sh
Manually (if required)
Go to the 'dummy' project:
cd ./spec/dummy
If this is your first time, create the test database
RAILS_ENV=test bundle exec rake db:create
Run migrations for test environment:
RAILS_ENV=test bundle exec rake db:migrate
Go back to gem root directory:
cd ../../
Run tests
bundle exec rake spec
Copyrights and License
- Initial Version Copyright (c) 2005 Trevor Squires
- Rails 3 Updates Copyright (c) 2010 Pivotal Labs
- Initial Test Suite Copyright (c) 2011 Sergey Potapov
- Subsequent Updates Copyright (c) 2011-2020 Arthur Shagall
Released under the MIT License. See the LICENSE file for more details.
Contributing
Contributions are welcome. However, please make sure of the following before issuing a pull request:
- All specs are passing.
- Any new features have test coverage. Use the SimpleCov report to confirm.
- Anything that breaks backward compatibility has a very good reason for doing so.