Enums in Ruby on Rails backed by PostgreSQL’s ENUM

Let me present you the way I usually create enums in Rails’ ActiveRecord models. I’ll be utilizing the capabilities of the underlying PostgreSQL database and its ENUM type.

Let’s start with an example Subscription model:

class Subscription < ApplicationRecord
  ACTIVE = 'active'.freeze
  INACTIVE = 'inactive'.freeze
  STATES = [ACTIVE, INACTIVE].freeze

  enum state: {
    active: ACTIVE,
    inactive: INACTIVE
  }

  validates :state, presence: true
end

The above will expect a string type state database field (instead of the default numeric one). Let’s create a migration for it:

class CreateSubscriptions < ActiveRecord::Migration[7.1]
  def change
    reversible do |direction|
      direction.up do
        execute <<-SQL
          CREATE TYPE subscription_state AS ENUM ('active', 'inactive');
        SQL
      end

      direction.down do
        execute <<-SQL
          DROP TYPE subscription_state;
        SQL
      end
    end

    create_table :subscriptions do |t|
      t.column :state, :subscription_state, default: 'active', null: false

      t.timestamps null: false
    end
  end
end

Notice that in the model I have intentionally left out the inclusion validation for the state field. This is because Rails will automatically raise ArgumentError if we try to assign a different value to it. As such, it is nice to automatically rescue from such situations in the ApplicationController:

class ApplicationController < ActionController::Base
  rescue_from ArgumentError, with: :bad_request
  ...

  private

  def bad_request exception
    message = exception.message

    respond_to do |format|
      format.html do
        render 'bad_request', status: :unprocessable_entity, locals: { message: }
      end

      format.json do
        render json: { status: 'ERROR', message: }, status: :unprocessable_entity
      end
    end
  end
end

Let’s add some tests. I’ll be using shoulda-matchers for some handy one-liners:

describe Subscription do
  specify ':state enum' do
    expect(described_class.new).to define_enum_for(:state)
      .with_values(active: 'active', inactive: 'inactive')
      .backed_by_column_of_type(:enum)
  end

  it { is_expected.to allow_values(:active, :inactive).for :state }

  it { is_expected.to validate_presence_of :state }

  describe ':state enum validation' do
    it 'raises ArgumentError when assigning an invalid value' do
      expect { described_class.new.state = 'canceled' }.to raise_exception ArgumentError
    end
  end
end

And an accompanying request spec for a most likely SubscriptionsController:

describe 'API subscriptions requests' do
  describe 'PATCH :update' do
    let(:subscription) { create :subscription }

    let(:params) { Hash[subscription: { state: 'invalid' }] }

    context 'when unsuccessful' do
      it 'responds with :unprocessable_entity with error details in the JSON response' do
        patch(subscriptions_path, params:)

        expect(response).to be_unprocessable

        expect(json_response).to be_a Hash
        expect(json_response['status']).to eql 'ERROR'
        expect(json_response['message']).to include 'ArgumentError'
      end
    end
  end
end

Voilà!

Published by

Paweł Gościcki

Ruby/Rails programmer.