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

Run Rails 2.3 application using Ruby 2.1

There is a way to have your old Rails 2.3.something application running using latest Ruby from the 2.1 branch. However, it is moderately complex and requires quite a few hacks. Not everything works perfect with this setup, though. Common exceptions are performance tests and some of the generators, but I regard those as minor annoyances.

I’m using 2-3-stable branch of Rails, which means version 2.3.18 plus some additional unreleased patches on top of it and a recently released Ruby 2.1.8.

This guide assumes that you are using Bundler to manage your gems. If not, please follow this guide first.

Gemfile

gem 'rails', :github => 'rails/rails', :branch => '2-3-stable'
gem 'rake'
gem 'json'
gem 'iconv'

group :test do
  gem 'test-unit', '1.2.3'
end

And then:

$ bundle update rails rake json iconv test-unit

Rakefile

Remove the following line:

require 'rake/rdoctask'

config/boot.rb

Change the following block after rescue Gem::LoadError => load_error to look like this:

if load_error.message =~ /Could not find RubyGem rails/
  STDERR.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.)
  exit 1
else
  raise
end

config/deploy.rb

Update your Ruby version used for Capistrano deployments. Only if you’re using Capistrano with RVM.

set :rvm_ruby_string, '2.1.8'

config/environment.rb

Change hardcoded Rails version:

RAILS_GEM_VERSION = '2.3.18' unless defined? RAILS_GEM_VERSION

Before Rails::Initializer.run block add the following:

# Rails 2.3 and Ruby 2.0+ compatibility hack
# http://blog.lucascaton.com.br/index.php/2014/02/28/have-a-rails-2-app-you-can-run-it-on-the-newest-ruby/
if RUBY_VERSION >= '2.0.0'
  module Gem
    def self.source_index
      sources
    end

    def self.cache
      sources
    end

    SourceIndex = Specification

    class SourceList
      # If you want vendor gems, this is where to start writing code.
      def search(*args); []; end
      def each(&block); end
      include Enumerable
    end
  end
end

Additional initializers

config/initializers/active_record_callbacks_ruby2.rb:

# ActiveRecord::Callbacks compatibility fix
# http://blog.lucascaton.com.br/index.php/2014/02/28/have-a-rails-2-app-you-can-run-it-on-the-newest-ruby/

module ActiveRecord
  module Callbacks

    private

    def callback(method)
      result = run_callbacks(method) { |result, object| false == result }

      # The difference is here, the respond_to must check protected methods.
      if result != false && respond_to_without_attributes?(method, true)
 result = send(method)
      end

      notify(method)

      return result
    end
  end
end

config/initializers/backport_3937.rb:

# Encoding issues with Ruby 2+
# backport #3937 to Rails 2.3.8
# https://github.com/rails/rails/pull/3937/files

class ERB
  module Util
    def html_escape(s)
      s = s.to_s
      if s.html_safe?
        s
      else
        silence_warnings { s.gsub(/[&"'><]/n) { |special| HTML_ESCAPE[special] }.html_safe }
      end
    end
  end
end

config/initializers/i18n_ruby2.rb:

# Patch to make i18n work with Ruby 2+
# http://blog.lucascaton.com.br/index.php/2014/02/28/have-a-rails-2-app-you-can-run-it-on-the-newest-ruby/

module I18n
  module Backend
    module Base
      def load_file(filename)
        type = File.extname(filename).tr('.', '').downcase

        raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true)

        data = send(:"load_#{type}", filename) # TODO raise a meaningful exception if this does not yield a Hash

        data.each { |locale, d| store_translations(locale, d) }
      end
    end
  end
end

config/initializers/rails_generators.rb:

# This is a very important monkey patch to make Rails 2.3.18 to work with Ruby 2+
# If you're thinking to remove it, really, don't, unless you know what you're doing.
# http://blog.lucascaton.com.br/index.php/2014/02/28/have-a-rails-2-app-you-can-run-it-on-the-newest-ruby/

if Rails::VERSION::MAJOR == 2 && RUBY_VERSION >= '2.0.0'
  require 'rails_generator'
  require 'rails_generator/scripts/generate'

  Rails::Generator::Commands::Create.class_eval do
    def template(relative_source, relative_destination, template_options = {})
      file(relative_source, relative_destination, template_options) do |file|
        # Evaluate any assignments in a temporary, throwaway binding
        vars = template_options[:assigns] || {}
        b = template_options[:binding] || binding
        # this no longer works, eval throws "undefined local variable or method `vars'"
        # vars.each { |k, v| eval "#{k} = vars[:#{k}] || vars['#{k}']", b }
        vars.each { |k, v| b.local_variable_set(:"#{k}", v) }

        # Render the source file with the temporary binding
        ERB.new(file.read, nil, '-').result(b)
      end
    end
  end
end

config/initializers/ruby2.rb:

# This is a very important monkey patch to make Rails 2.3.18 to work with Ruby 2+
# If you're thinking to remove it, really, don't, unless you know what you're doing.
# http://blog.lucascaton.com.br/index.php/2014/02/28/have-a-rails-2-app-you-can-run-it-on-the-newest-ruby/

if Rails::VERSION::MAJOR == 2 && RUBY_VERSION >= '2.0.0'
  module ActiveRecord
    module Associations
      class AssociationProxy
        def send(method, *args)
          if proxy_respond_to?(method, true)
            super
          else
            load_target
            @target.send(method, *args)
          end
        end
      end
    end
  end
end

config/initializers/string_encodings.rb:

# Set default encoding for everything coming in and out of the app
# TODO this could/should be removed when upgrading to Rails 3+
Encoding.default_external = Encoding::UTF_8
Encoding.default_internal = Encoding::UTF_8

config/initializers/utf8_params.rb

# convert all params into UTF-8 (from ASCII-8BIT)
# http://jasoncodes.com/posts/ruby19-rails2-encodings

raise "Check if this is still needed on " + Rails.version unless Rails.version == '2.3.18'

class ActionController::Base
  def force_utf8_params
    traverse = lambda do |object, block|
      if object.kind_of?(Hash)
        object.each_value { |o| traverse.call(o, block) }
      elsif object.kind_of?(Array)
        object.each { |o| traverse.call(o, block) }
      else
        block.call(object)
      end
      object
    end
    force_encoding = lambda do |o|
      o.force_encoding(Encoding::UTF_8) if o.respond_to?(:force_encoding)
    end
    traverse.call(params, force_encoding)
  end

  before_filter :force_utf8_params
end

References: