class CreateSubscriptions < ActiveRecord::Migration[7.1]
STATUSES = %w[pending active past_due canceled].freeze
def change
create_table :subscriptions do |t|
t.references :account, null: false, foreign_key: true
t.string :status, null: false, default: "pending"
t.datetime :current_period_end
t.timestamps
end
quoted = STATUSES.map { |s| ActiveRecord::Base.connection.quote(s) }.join(", ")
add_check_constraint :subscriptions,
"status IN (#{quoted})",
name: "subscriptions_status_check"
add_index :subscriptions, :account_id,
unique: true,
where: "status = 'active'",
name: "index_active_subscription_per_account"
end
end
class Subscription < ApplicationRecord
STATUSES = %w[pending active past_due canceled].freeze
belongs_to :account
enum status: STATUSES.index_with(&:itself), _prefix: true
validates :status, presence: true,
inclusion: { in: STATUSES, message: "%{value} is not a valid status" }
scope :billable, -> { where(status: %w[active past_due]) }
def activate!
with_lock do
update!(status: "active", current_period_end: 1.month.from_now)
end
end
def mark_past_due!
status_active? ? update!(status: "past_due") : false
end
end
class SubscriptionsController < ApplicationController
rescue_from ActiveRecord::StatementInvalid, with: :handle_constraint_violation
def create
subscription = current_account.subscriptions.new(subscription_params)
if subscription.save
render json: subscription, status: :created
else
render json: { errors: subscription.errors.full_messages }, status: :unprocessable_entity
end
end
def update
subscription = current_account.subscriptions.find(params[:id])
if subscription.update(subscription_params)
render json: subscription
else
render json: { errors: subscription.errors.full_messages }, status: :unprocessable_entity
end
end
private
def subscription_params
params.require(:subscription).permit(:status, :current_period_end)
end
def handle_constraint_violation(error)
raise unless error.message.include?("subscriptions_status_check")
render json: { errors: ["status violates the database constraint"] },
status: :unprocessable_entity
end
end
Rails enum maps a symbolic name like :pending to a stored value, but by default nothing at the database level prevents an out-of-range integer or an unexpected string from landing in the column. That gap matters when data arrives from bulk imports, raw SQL, other services, or a rollback that leaves an old code path writing values the current app no longer understands. This snippet closes the gap by backing the Rails enum with a Postgres CHECK constraint so the database itself rejects invalid states.
The CreateSubscriptions migration stores status as a string rather than an integer. String-backed enums keep the raw column readable in psql and in dumps, and they survive reordering of the enum declaration — an integer enum silently changes meaning if someone inserts a value in the middle of the list. The migration adds check_constraint with an explicit status IN (...) list and names it subscriptions_status_check so it can be found and altered later. A partial index on active rows and a null: false default round out the schema-level guarantees.
In Subscription model, the same set of statuses is declared as STATUSES and passed to enum status:, keeping the Ruby-side and database-side definitions driven from one array. Using validate: true (via validates :status, inclusion:) gives a friendly ActiveRecord validation error before the row ever reaches the database, while the CHECK constraint remains the last line of defense for writes that bypass validations, such as update_column or insert_all. The _prefix: true option namespaces the generated scopes and predicate methods to avoid collisions with other columns.
The SubscriptionsController shows the two failure modes working together: normal create and update surface validation errors as 422s, while rescue_from ActiveRecord::StatementInvalid catches a constraint violation that slipped past validation and reports it explicitly. This layered approach — application validation for UX, database constraint for correctness — means the invariant holds no matter which code path writes the row. The trade-off is that the allowed list now lives in two places and must be changed together through a migration, which is a deliberate cost paid for a truly enforceable enum.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.