<jack>

If it’s too hard you’re doing it wrong.

Simple Enums in Ruby with the meta_enum Gem

Posted at — Feb 4, 2018

When working with data that has a numerical representation it is common to map symbolic names to the underlying number. A common example is HTTP status codes. “Forbidden” is easier to understand than 403, “Not found” is easier to understand than 404, etc. For these cases it is convenient for the developer to use a type the uses a symbolic name in the source code that is internally mapped to the underlying number. These are commonly referred to a enum types.

Ruby does not have a built-in enum type. Typically symbols or constants are used instead. There can be a few deficiencies in this approach. It requires extra effort to detect or prevent invalid values or symbols. It can also be inconvient to compare and convert between value and symbolic representation.

Introduction

The meta_enum gem offers a solution.

require 'meta_enum'

HTTPStatus = MetaEnum::Type.new(ok: 200, forbidden: 403, not_found: 404)

The [] method is the “do what I mean” operator.

You can lookup a value by name:

HTTPStatus[:not_found] # => #<MetaEnum::Element: not_found: 404, data: nil>

Or by number:

HTTPStatus[403] # => #<MetaEnum::Element: forbidden: 403, data: nil>

Missing names would almost always be a programming error, so that will raise an exception.

HTTPStatus[:purple] # => raises: KeyError: key not found: :purple

But missing values could mean that there are values defined externally we do not know about. So it is preferable not to raise an exception.

HTTPStatus[201] # => #<MetaEnum::MissingElement: 201}>

Value and name can be retrieved from a MetaEnum::Element

s = HTTPStatus[:ok] # => #<MetaEnum::Element: ok: 200, data: nil>
s.value # => 200
s.name # => :ok

Values can easily be compared with symbolic and numeric representations.

s = HTTPStatus[:ok] # => #<MetaEnum::Element: ok: 200, data: nil>
s == 200 # => true
s == :ok # => true
HTTPStatus[:ok] == 200 # => true
HTTPStatus[:ok] == HTTPStatus[:ok] # => true

Values can also have extra associated data.

AgeType = MetaEnum::Type.new(child: [0, "Less than 18"], adult: [1, "At least 18"])
AgeType[:child].data # => "Less than 18"

Use with ActiveRecord

An ideal use of MetaEnum is mapping foreign keys to lookup tables with ActiveRecord. Consider the domain of users. A user may be in the following statuses: active, locked, requires_confirmation, deleted, etc.

The type of statuses may be stored in a separate table of customer_status_types and the customers table has a foreign key to that table. This is good from a relational data modeling point of view, but can be inconvient to deal with in ActiveRecord.

But combine ActiveRecord with MetaEnum and it becomes easier. First, initialize the MetaEnum::Type with the values from the database.

CustomerStatusType = MetaEnum::Type.new(
  ActiveRecord::Base.connection
    .select_all("select * from customer_status_types")
    .each_with_object({}) do |row, h|
      h[row["internal_name"].to_sym] = [row["id"], row["name"]]
    end
)

Next, create a reader and writer method in the Customer model.

class Customer < ApplicationRecord
  def status
    customer_status_type_id && CustomerStatusType[customer_status_type_id]
  end

  def status=(val)
    self.customer_status_type_id = CustomerStatusType[val].value
  end
end

At this point we can read and write status in customer by symbol.

c = Customer.first
if c.status == :requires_confirmation
  c.update! status: :active
end

Check it out at Github.