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à!