Skip to content

Latest commit

 

History

History
815 lines (570 loc) · 26.8 KB

README.markdown

File metadata and controls

815 lines (570 loc) · 26.8 KB

Power Enum

https://github.com/albertosaurus/power_enum_2

Build Status Code Climate Gem Version

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 is nil, returns false.
  • If arg is an instance of Symbol, Integer or String, returns the result of BookingStatus[:foo] == BookingStatus[arg].
  • If arg is an Array, returns true if any member of the array returns true 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:

  1. 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.

  2. 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.

  1. 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.